Merged in feature/provider-state (pull request #602)
Feature/provider state * Add prompt for extracting provider state information in investment prompts * Add state normalization function and integrate into context field extraction * black format string_utils.py; add tests for normalize_state_to_abbreviation function * black format string_utils_test.py * Add PROVIDER_STATE to investment column order * Merged main into feature/provider-state Approved-by: Katon Minhas
This commit is contained in:
@@ -10,8 +10,11 @@ import src.utils.llm_utils as llm_utils
|
||||
from src import config
|
||||
from src.enums.delimiters import Delimiter
|
||||
from src.prompts import preprocessing_prompts
|
||||
from src.regex.regex_patterns import (BACKTICK_PATTERN, PIPE_PATTERN,
|
||||
TRIPLE_BACKTICK_PATTERN)
|
||||
from src.regex.regex_patterns import (
|
||||
BACKTICK_PATTERN,
|
||||
PIPE_PATTERN,
|
||||
TRIPLE_BACKTICK_PATTERN,
|
||||
)
|
||||
|
||||
|
||||
def extract_text_from_delimiters(
|
||||
@@ -71,7 +74,8 @@ def extract_text_from_delimiters(
|
||||
|
||||
return matches[match_index]
|
||||
|
||||
def page_key_sort(page_key: str) -> tuple[int, float|str]:
|
||||
|
||||
def page_key_sort(page_key: str) -> tuple[int, float | str]:
|
||||
"""Sorts page keys, prioritizing numeric keys first.
|
||||
|
||||
Args:
|
||||
@@ -85,14 +89,14 @@ def page_key_sort(page_key: str) -> tuple[int, float|str]:
|
||||
(0, 1.0)
|
||||
>>> page_key_sort("A")
|
||||
(1, "A")
|
||||
|
||||
|
||||
|
||||
|
||||
Example Usage with mixed types:
|
||||
>>> L = ["10", "2", "A", "1"]
|
||||
>>> sort(L, key=page_key_sort)
|
||||
>>> print(L)
|
||||
['1', '2', '10', 'A']
|
||||
|
||||
|
||||
"""
|
||||
try:
|
||||
return (0, float(page_key)) # Numeric pages first, in numerical order
|
||||
@@ -129,18 +133,18 @@ def json_parsing_search(response_text: str, field_list: list[str]) -> dict[str,
|
||||
response_text = response_text.rsplit("}", 1)[0]
|
||||
else:
|
||||
response_text = response_text.rstrip(",") + "}"
|
||||
|
||||
|
||||
field_l = []
|
||||
answer_l = []
|
||||
position_dict = {}
|
||||
|
||||
|
||||
for f in field_list:
|
||||
location = response_text.find('"' + f + '"')
|
||||
if location != -1:
|
||||
position_dict[location] = f
|
||||
|
||||
|
||||
field_list = list(dict(sorted(position_dict.items())).values())
|
||||
|
||||
|
||||
for f in field_list:
|
||||
if f in response_text:
|
||||
field_l.append(f)
|
||||
@@ -253,6 +257,7 @@ def primary_string_to_dict(string_dict, filename):
|
||||
data.append(dict_)
|
||||
return data
|
||||
|
||||
|
||||
def universal_json_load(string_dict: str):
|
||||
"""Matches json dicts and list of dicts - NOT simple lists
|
||||
Args:
|
||||
@@ -262,10 +267,10 @@ def universal_json_load(string_dict: str):
|
||||
Raises:
|
||||
ValueError: If no valid JSON object is found in the input string. In addition,
|
||||
the traceback as well as the offending string are printed.
|
||||
|
||||
|
||||
"""
|
||||
# Try to match dictionary or list of dictionaries first
|
||||
dict_match = re.search(r'\{.*\}|\[\s*?\{.*\}\s*?\]', string_dict, re.DOTALL)
|
||||
dict_match = re.search(r"\{.*\}|\[\s*?\{.*\}\s*?\]", string_dict, re.DOTALL)
|
||||
if dict_match:
|
||||
matched_str = dict_match.group()
|
||||
try:
|
||||
@@ -275,32 +280,36 @@ def universal_json_load(string_dict: str):
|
||||
print(f"Original string: {string_dict}")
|
||||
print(f"Traceback: {traceback.format_exc()}")
|
||||
raise ValueError(f"JSON parsing failed: {e.msg} at position {e.pos}") from e
|
||||
|
||||
|
||||
# If no dict pattern found, try to match list of strings
|
||||
list_match = re.search(r'\[(.*?)\]', string_dict, re.DOTALL)
|
||||
list_match = re.search(r"\[(.*?)\]", string_dict, re.DOTALL)
|
||||
if list_match:
|
||||
matched_str = list_match.group()
|
||||
try:
|
||||
# Clean up the string list before parsing
|
||||
cleaned_str = re.sub(r'(?<!\\)"', '"', matched_str) # Handle escaped quotes
|
||||
cleaned_str = re.sub(r"'", '"', cleaned_str) # Replace single quotes with double quotes
|
||||
cleaned_str = re.sub(
|
||||
r"'", '"', cleaned_str
|
||||
) # Replace single quotes with double quotes
|
||||
return json.loads(cleaned_str)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Failed to parse list: {e}")
|
||||
print(f"Original string: {string_dict}")
|
||||
print(f"Traceback: {traceback.format_exc()}")
|
||||
raise ValueError(f"JSON parsing failed: {e.msg} at position {e.pos}") from e
|
||||
|
||||
|
||||
return {}
|
||||
|
||||
reimbursement_strings = [ # These are for `method='keyword'`
|
||||
|
||||
reimbursement_strings = [ # These are for `method='keyword'`
|
||||
"%",
|
||||
"$",
|
||||
"percent",
|
||||
"billed charges"
|
||||
"billed charges",
|
||||
]
|
||||
reimb_regex = r"(?<![$%])(?:\$\d+|\d+[$%])(?![$%])"
|
||||
|
||||
|
||||
# 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
|
||||
def contains_reimbursement(text, page="1", method="keyword"):
|
||||
"""
|
||||
@@ -329,7 +338,8 @@ def contains_reimbursement(text, page="1", method="keyword"):
|
||||
else:
|
||||
raise ValueError("Invalid method. Choose 'keyword' or 'regex'.")
|
||||
|
||||
def count_reimbursements_in_exhibit(exhibit_text: str) -> int: #JUST by regex
|
||||
|
||||
def count_reimbursements_in_exhibit(exhibit_text: str) -> int: # JUST by regex
|
||||
"""Counts reimbursements in an exhibit text. Reimbursements are detected by a regex
|
||||
search as defined by `reimb_regex`.
|
||||
|
||||
@@ -341,15 +351,16 @@ def count_reimbursements_in_exhibit(exhibit_text: str) -> int: #JUST by regex
|
||||
"""
|
||||
return len(re.findall(reimb_regex, exhibit_text))
|
||||
|
||||
def is_empty(value: str | list | pd.Series, pd_mask: bool=True) -> bool | pd.Series:
|
||||
|
||||
def is_empty(value: str | list | pd.Series, pd_mask: bool = True) -> bool | pd.Series:
|
||||
"""
|
||||
Checks if a value is considered empty or invalid.
|
||||
|
||||
Args:
|
||||
value: The value to check.
|
||||
Can be a string, list, or pandas Series.
|
||||
pd_mask (bool): Only relevant for Series inputs.
|
||||
If True, returns a mask for empty values in a pandas Series.
|
||||
pd_mask (bool): Only relevant for Series inputs.
|
||||
If True, returns a mask for empty values in a pandas Series.
|
||||
Otherwise, the function returns True iff the entire Series is empty.
|
||||
|
||||
Returns:
|
||||
@@ -361,13 +372,15 @@ def is_empty(value: str | list | pd.Series, pd_mask: bool=True) -> bool | pd.Ser
|
||||
- If `pd_mask` is False, returns True iff all elements are empty/invalid, or if the entire Series is empty.
|
||||
"""
|
||||
empty_values = [None, "", "N/A", "NA", "null", "none", "NaN", np.nan, "nan", "None"]
|
||||
|
||||
|
||||
if isinstance(value, list): # Handle list inputs
|
||||
return not value or all(is_empty(v) for v in value)
|
||||
|
||||
|
||||
# if it's a pd.Series, return the mask (True for empty values)
|
||||
if isinstance(value, pd.Series):
|
||||
return value.isna() | (value.isin(empty_values)) if pd_mask else value.isna().all()
|
||||
return (
|
||||
value.isna() | (value.isin(empty_values)) if pd_mask else value.isna().all()
|
||||
)
|
||||
|
||||
if pd.isna(value):
|
||||
return True
|
||||
@@ -378,9 +391,10 @@ def is_empty(value: str | list | pd.Series, pd_mask: bool=True) -> bool | pd.Ser
|
||||
def datetime_str():
|
||||
return datetime.now().strftime("[%Y-%m-%d %H:%M:%S]")
|
||||
|
||||
|
||||
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.
|
||||
First tries to match the textract marker "this page has N signature(s)", then falls back to looking
|
||||
First tries to match the textract marker "this page has N signature(s)", then falls back to looking
|
||||
for the word "signature" if no matches are found. "this page has N signature(s)" is Textract's marker for signatures
|
||||
|
||||
Args:
|
||||
@@ -394,19 +408,25 @@ def extract_signature_page(text_dict: dict, filename: str) -> dict:
|
||||
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)
|
||||
textract_pattern = re.compile(r"this page has (?:\d+|one|two|three|four|five|six|seven|eight|nine|ten) signature", re.IGNORECASE)
|
||||
|
||||
textract_pattern = re.compile(
|
||||
r"this page has (?:\d+|one|two|three|four|five|six|seven|eight|nine|ten) signature",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# If no pages found with textract pattern, try with keyword
|
||||
if not signature_pages:
|
||||
page_items = list(text_dict.items())
|
||||
for i, (page_num, page_text) in enumerate(page_items):
|
||||
lower_text = page_text.lower()
|
||||
if "signature page follow" in lower_text or "signature authorization" in lower_text:
|
||||
if (
|
||||
"signature page follow" in lower_text
|
||||
or "signature authorization" in lower_text
|
||||
):
|
||||
# curr_page = int(float(page_num))
|
||||
if page_num not in signature_pages:
|
||||
signature_pages[page_num] = page_text # current page
|
||||
@@ -414,8 +434,7 @@ def extract_signature_page(text_dict: dict, filename: str) -> dict:
|
||||
next_page_num, next_page_text = page_items[i + 1]
|
||||
# next_page = int(float(next_page_num))
|
||||
if next_page_num not in signature_pages:
|
||||
signature_pages[next_page_num] = next_page_text # next page
|
||||
|
||||
signature_pages[next_page_num] = next_page_text # next page
|
||||
|
||||
return signature_pages
|
||||
|
||||
@@ -424,9 +443,9 @@ 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.
|
||||
|
||||
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
|
||||
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
|
||||
in the result, as it often contains effective date.
|
||||
|
||||
Args:
|
||||
@@ -434,7 +453,7 @@ def extract_effective_date_pages(text_dict: dict, filename: str) -> dict:
|
||||
filename (str): The name of the file being processed.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the extracted effective date page(s),
|
||||
dict: A dictionary containing the extracted effective date page(s),
|
||||
where keys are page numbers and values are the corresponding page text.
|
||||
"""
|
||||
|
||||
@@ -442,16 +461,129 @@ def extract_effective_date_pages(text_dict: dict, filename: str) -> dict:
|
||||
|
||||
# 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).
|
||||
textract_pattern = re.compile(r"this page has (?:\d+|one|two|three|four|five|six|seven|eight|nine|ten) signature", re.IGNORECASE)
|
||||
|
||||
textract_pattern = re.compile(
|
||||
r"this page has (?:\d+|one|two|three|four|five|six|seven|eight|nine|ten) signature",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# 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:'.
|
||||
if textract_pattern.search(page_text) or 'effective date:' in page_text.lower():
|
||||
if textract_pattern.search(page_text) or "effective date:" in page_text.lower():
|
||||
effective_date_pages[page_num] = page_text
|
||||
# Always include the first page (page 1) as it often contains effective date.
|
||||
elif page_num == 1 or page_num == "1" or page_num == "1.0":
|
||||
effective_date_pages[page_num] = page_text
|
||||
|
||||
return effective_date_pages
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user