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:
Alex Galarce
2025-07-07 21:42:49 +00:00
parent 5a8adf838d
commit 479675b05c
5 changed files with 587 additions and 139 deletions
@@ -10,6 +10,7 @@ COLUMN_ORDER = [
"AARETE_DERIVED_AMENDMENT_NUM",
"CLIENT_NAME",
"PAYER_NAME",
"PROVIDER_STATE",
"PROV_GROUP_TIN",
"PROV_GROUP_NPI",
"PROV_GROUP_NAME_FULL",
@@ -237,6 +237,12 @@ def run_full_context_fields(
full_context_answers_dict = {}
for field in full_context_fields.list_fields():
full_context_answers_dict[field] = f"Error: {str(e)}"
# Normalize PROVIDER_STATE to standardized two-letter abbreviation
if "PROVIDER_STATE" in full_context_answers_dict:
full_context_answers_dict["PROVIDER_STATE"] = string_utils.normalize_state_to_abbreviation(
full_context_answers_dict["PROVIDER_STATE"]
)
# Handle separate one-to-one fields that require individual processing
separate_fields = one_to_one_fields.filter(field_type="full_context_separate")
@@ -210,6 +210,12 @@
"field_type": "full_context",
"prompt": "Extract the amendment number if this document is an amendment to a contract. Follow these guidelines:\n1. Look for phrases like 'Amendment No. X', 'X Amendment', 'Amendment X to the Agreement', 'Xth Amendment' in the document title, preamble, or header sections.\n2. Return only the numeric value (e.g., for 'Amendment No. 3' return '3').\n3. If the amendment number is written as a word (e.g., 'First Amendment'), convert it to a number (e.g., '1').\n4. If the amendment number is an alphanumeric value, return the whole value (e.g., for '2A' return '2A').\n5. If the amendment number uses Roman numerals (e.g., 'Amendment IV'), convert it to a number (e.g., '4').\n6. If the amendment number includes decimal points (e.g., 'Amendment 2.1'), return the full numeric value (e.g., '2.1').\n7. If multiple amendment numbers appear, return the one most clearly associated with the current document.\n8. If the document is an amendment but the number cannot be determined, return 'UNKNOWN'.\n9. If the document is not an amendment, return 'N/A'.\n10. If the Amendment number contains any leading zeroes(e.g. '001'), Drop all leading zeros (e.g. '1').\n11. If the document is an amendment but the number cannot be determined, return '1'."
},
{
"field_name": "PROVIDER_STATE",
"relationship": "one_to_one",
"field_type": "full_context",
"prompt": "What US state is the provider based in or where does this contract apply? Look for:\n\n1. Provider address information in the contract header, preamble, or signature sections\n2. State names or abbreviations in provider contact information\n3. Language indicating the governing state or jurisdiction (e.g., 'governed by the laws of [State]')\n4. Service area or coverage area specifications\n5. Provider licensing information that mentions a specific state\n\nReturn the full state name (e.g., 'California', 'Texas', 'New York') or the standard 2-letter abbreviation (e.g., 'CA', 'TX', 'NY') as it appears in the document.\n\nIf multiple states are mentioned, prioritize:\n1. The state in the provider's primary address\n2. The state mentioned in governing law clauses\n3. The state most frequently referenced in the contract\n\nIf no state can be determined, return 'N/A'."
},
{
"field_name": "CLIENT_NAME",
"relationship": "one_to_one",
+171 -39
View File
@@ -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
+403 -100
View File
@@ -1,42 +1,122 @@
import pytest
import src.utils as utils
from src.utils.string_utils import extract_text_from_delimiters, json_parsing_search, secondary_string_to_dict, primary_string_to_dict, contains_reimbursement, is_empty, count_reimbursements_in_exhibit, extract_signature_page, page_key_sort
from src.utils.string_utils import (
extract_text_from_delimiters,
json_parsing_search,
secondary_string_to_dict,
primary_string_to_dict,
contains_reimbursement,
is_empty,
count_reimbursements_in_exhibit,
extract_signature_page,
page_key_sort,
)
import src.utils.llm_utils as llm_utils
import numpy as np
import pandas as pd
from src.enums.delimiters import Delimiter
class TestStringUtils:
@pytest.mark.parametrize("raw_text, delimiter, match_index, expected", [
("Here is the answer |this is a pipe answer| and |more text.|", Delimiter.PIPE, 0, "this is a pipe answer"),
("Here is the answer |this is a pipe answer| and |more text.|", Delimiter.PIPE, -1, "more text."),
("|Here| is the answer |this is a pipe answer| and |more text.|", Delimiter.PIPE, 1, "this is a pipe answer"),
("Here is the answer `this is a backtick answer` and `more text.`", Delimiter.BACKTICK, 0, "this is a backtick answer"),
("Here is the answer `this is a backtick answer` and `more text.`", Delimiter.BACKTICK, -1, "more text."),
("`Here` is the answer `this` is a `backtick answer` and `more text.`", Delimiter.BACKTICK, 2, "backtick answer"),
("Here is the answer ```this is a triple backtick answer``` and more text.", Delimiter.TRIPLE_BACKTICK, 0, "this is a triple backtick answer"),
("Here is the answer ```this``` is a ```triple``` backtick ```answer``` and more text.", Delimiter.TRIPLE_BACKTICK, -1, "answer"),
("```Here``` is the answer ```this is a triple backtick answer``` and ```more text.```", Delimiter.TRIPLE_BACKTICK, 1, "this is a triple backtick answer"),
])
def test_extract_text_from_delimiters(self, raw_text, delimiter, match_index, expected):
@pytest.mark.parametrize(
"raw_text, delimiter, match_index, expected",
[
(
"Here is the answer |this is a pipe answer| and |more text.|",
Delimiter.PIPE,
0,
"this is a pipe answer",
),
(
"Here is the answer |this is a pipe answer| and |more text.|",
Delimiter.PIPE,
-1,
"more text.",
),
(
"|Here| is the answer |this is a pipe answer| and |more text.|",
Delimiter.PIPE,
1,
"this is a pipe answer",
),
(
"Here is the answer `this is a backtick answer` and `more text.`",
Delimiter.BACKTICK,
0,
"this is a backtick answer",
),
(
"Here is the answer `this is a backtick answer` and `more text.`",
Delimiter.BACKTICK,
-1,
"more text.",
),
(
"`Here` is the answer `this` is a `backtick answer` and `more text.`",
Delimiter.BACKTICK,
2,
"backtick answer",
),
(
"Here is the answer ```this is a triple backtick answer``` and more text.",
Delimiter.TRIPLE_BACKTICK,
0,
"this is a triple backtick answer",
),
(
"Here is the answer ```this``` is a ```triple``` backtick ```answer``` and more text.",
Delimiter.TRIPLE_BACKTICK,
-1,
"answer",
),
(
"```Here``` is the answer ```this is a triple backtick answer``` and ```more text.```",
Delimiter.TRIPLE_BACKTICK,
1,
"this is a triple backtick answer",
),
],
)
def test_extract_text_from_delimiters(
self, raw_text, delimiter, match_index, expected
):
result = extract_text_from_delimiters(raw_text, delimiter, match_index)
assert result == expected
@pytest.mark.parametrize("response_text, field_list, expected", [
('{"name": "John", "age": "30", "city": "New York"}', ["name", "age", "city"], {"name": "John", "age": "30", "city": "New York"}),
('{"name": "John", "city": "New York"}', ["name", "age", "city"], {"name": "John", "city": "New York"}),
('', ["name", "age", "city"], {}),
('{"name": "John", "age": "30", "city": "New York"}', [], {}),
('{"name": "", "age": "", "city": ""}', ["name", "age", "city"], {"name": "", "age": "", "city": ""}),
('{\n"name": "John",\n"age": "30",\n"city": "New York"\n}', ["name", "age", "city"], {"name": "John", "age": "30", "city": "New York"}),
])
@pytest.mark.parametrize(
"response_text, field_list, expected",
[
(
'{"name": "John", "age": "30", "city": "New York"}',
["name", "age", "city"],
{"name": "John", "age": "30", "city": "New York"},
),
(
'{"name": "John", "city": "New York"}',
["name", "age", "city"],
{"name": "John", "city": "New York"},
),
("", ["name", "age", "city"], {}),
('{"name": "John", "age": "30", "city": "New York"}', [], {}),
(
'{"name": "", "age": "", "city": ""}',
["name", "age", "city"],
{"name": "", "age": "", "city": ""},
),
(
'{\n"name": "John",\n"age": "30",\n"city": "New York"\n}',
["name", "age", "city"],
{"name": "John", "age": "30", "city": "New York"},
),
],
)
def test_json_parsing_search(self, response_text, field_list, expected):
assert json_parsing_search(response_text, field_list) == expected
@pytest.fixture
def mock_invoke_claude(self, mocker):
return mocker.patch.object(llm_utils, 'invoke_claude')
return mocker.patch.object(llm_utils, "invoke_claude")
def test_valid_json(self, mock_invoke_claude):
dict_string = '{"key": "value"}'
@@ -69,7 +149,7 @@ class TestStringUtils:
mock_invoke_claude.assert_called_once()
def test_no_dict_found(self, mock_invoke_claude):
mock_invoke_claude.return_value = '{}'
mock_invoke_claude.return_value = "{}"
dict_string = "This is a plain text without any dictionary."
filename = "test_file.txt"
result = secondary_string_to_dict(dict_string, filename)
@@ -160,13 +240,37 @@ class TestStringUtils:
assert result == expected_output
assert mock_secondary_string_to_dict.call_count == 4
@pytest.mark.parametrize("text, page, expected", [
({"1": "This page contains a 10% reimbursement schedule.", "2": "No relevant content here."}, "1", True),
({"1": "This page has no relevant content.", "2": "Still no relevant content here."}, "1", False),
({"1": "This page contains a 10% reimbursement schedule.", "2": "No relevant content here."}, "invalid_page", False),
("This text contains a $100 reimbursement schedule.", "1", True),
("This text has no relevant content.", "1", False),
])
@pytest.mark.parametrize(
"text, page, expected",
[
(
{
"1": "This page contains a 10% reimbursement schedule.",
"2": "No relevant content here.",
},
"1",
True,
),
(
{
"1": "This page has no relevant content.",
"2": "Still no relevant content here.",
},
"1",
False,
),
(
{
"1": "This page contains a 10% reimbursement schedule.",
"2": "No relevant content here.",
},
"invalid_page",
False,
),
("This text contains a $100 reimbursement schedule.", "1", True),
("This text has no relevant content.", "1", False),
],
)
def test_contains_reimbursement(self, text, page, expected):
result = contains_reimbursement(text, page)
assert result == expected
@@ -179,75 +283,99 @@ class TestStringUtils:
captured = capsys.readouterr()
assert "contains_reimbursement - Invalid data type" in captured.out
@pytest.mark.parametrize("value, expected", [
(None, True),
("", True),
("N/A", True),
("null", True),
("none", True),
("NaN", True),
(np.nan, True),
(pd.NA, True),
("This is not empty.", False),
(42, False),
])
@pytest.mark.parametrize(
"value, expected",
[
(None, True),
("", True),
("N/A", True),
("null", True),
("none", True),
("NaN", True),
(np.nan, True),
(pd.NA, True),
("This is not empty.", False),
(42, False),
],
)
def test_is_empty(self, value, expected):
result = is_empty(value)
assert result == expected
@pytest.mark.parametrize("text_dict, filename, expected", [
(
{"1": "This page has 2 signatures.", "2": "None here.", "3": "Signature of the party (but ignored)"},
"test_file.txt",
{"1": "This page has 2 signatures."}
),
(
{"1": "No relevant content here.", "2": "Nor over here."},
"test_file.txt",
{}
),
(
{"1": "This page has a signature.", "2": "signature page follow.","3": "Signature of the party (but ignored)"},
"test_file.txt",
{"2": "signature page follow.","3": "Signature of the party (but ignored)"}
),
(
{"1": "This page has 1 signature.", "2": "No relevant content.", "3": "Signature block here. But ignored bc of the first textract-like page"},
"test_file.txt",
{"1": "This page has 1 signature."}
),
(
{"1": "No signed names at all.", "2": "Just some random text."},
"test_file.txt",
{}
),
(
{"1": "some random text", "2": "Signature authorization", "3": "Signature of the party"},
"test_file.txt",
{"2": "Signature authorization", "3": "Signature of the party"}
),
(
{},
"test_file.txt",
{}
),
])
@pytest.mark.parametrize(
"text_dict, filename, expected",
[
(
{
"1": "This page has 2 signatures.",
"2": "None here.",
"3": "Signature of the party (but ignored)",
},
"test_file.txt",
{"1": "This page has 2 signatures."},
),
(
{"1": "No relevant content here.", "2": "Nor over here."},
"test_file.txt",
{},
),
(
{
"1": "This page has a signature.",
"2": "signature page follow.",
"3": "Signature of the party (but ignored)",
},
"test_file.txt",
{
"2": "signature page follow.",
"3": "Signature of the party (but ignored)",
},
),
(
{
"1": "This page has 1 signature.",
"2": "No relevant content.",
"3": "Signature block here. But ignored bc of the first textract-like page",
},
"test_file.txt",
{"1": "This page has 1 signature."},
),
(
{"1": "No signed names at all.", "2": "Just some random text."},
"test_file.txt",
{},
),
(
{
"1": "some random text",
"2": "Signature authorization",
"3": "Signature of the party",
},
"test_file.txt",
{"2": "Signature authorization", "3": "Signature of the party"},
),
({}, "test_file.txt", {}),
],
)
def test_extract_signature_page(self, text_dict, filename, expected):
result = extract_signature_page(text_dict, filename)
assert result == expected
@pytest.mark.parametrize("page_key, expected", [
("1", (0, 1)), # Numeric key
("10", (0, 10)), # Larger numeric key
("A", (1, "A")), # Alphabetic key
("Z", (1, "Z")), # Another alphabetic key
("1A", (1, "1A")), # Alphanumeric key
("", (1, "")), # Empty string
("001", (0, 1)), # Numeric key with leading zeros
("B2", (1, "B2")), # Alphanumeric key with letter first
("-5", (0, -5)), # Negative numeric
(" ", (1, " ")), # Space character
])
@pytest.mark.parametrize(
"page_key, expected",
[
("1", (0, 1)), # Numeric key
("10", (0, 10)), # Larger numeric key
("A", (1, "A")), # Alphabetic key
("Z", (1, "Z")), # Another alphabetic key
("1A", (1, "1A")), # Alphanumeric key
("", (1, "")), # Empty string
("001", (0, 1)), # Numeric key with leading zeros
("B2", (1, "B2")), # Alphanumeric key with letter first
("-5", (0, -5)), # Negative numeric
(" ", (1, " ")), # Space character
],
)
def test_page_key_sort(self, page_key, expected):
result = page_key_sort(page_key)
assert result == expected
@@ -258,16 +386,191 @@ class TestStringUtils:
sorted_keys = sorted(keys, key=page_key_sort)
assert sorted_keys == expected_sorted_keys
class TestCountReimbursementsInExhibit:
@pytest.mark.parametrize("exhibit_text, expected_count", [
("This exhibit includes a 10% reimbursement and a $100 reimbursement.", 2),
("No reimbursements mentioned here.", 0),
("Reimbursement of 50% and another reimbursement of $200.", 2),
("100 percent reimbursement and fifty dollars reimbursement.", 0),
("", 0),
("Reimbursement: 20% and $300.", 2),
("Reimbursement: 20% and $300. Another 10% reimbursement.", 3),
])
@pytest.mark.parametrize(
"exhibit_text, expected_count",
[
("This exhibit includes a 10% reimbursement and a $100 reimbursement.", 2),
("No reimbursements mentioned here.", 0),
("Reimbursement of 50% and another reimbursement of $200.", 2),
("100 percent reimbursement and fifty dollars reimbursement.", 0),
("", 0),
("Reimbursement: 20% and $300.", 2),
("Reimbursement: 20% and $300. Another 10% reimbursement.", 3),
],
)
def test_count_reimbursements_in_exhibit(self, exhibit_text, expected_count):
result = count_reimbursements_in_exhibit(exhibit_text)
assert result == expected_count
class TestNormalizeStateToAbbreviation:
@pytest.mark.parametrize(
"state_value, expected",
[
# Special values should be preserved
("N/A", "N/A"),
("UNKNOWN", "UNKNOWN"),
("", ""),
(None, None),
# Valid 2-letter abbreviations should be uppercased
("ca", "CA"),
("CA", "CA"),
("ny", "NY"),
("TX", "TX"),
("fl", "FL"),
# Full state names should be converted to abbreviations
("california", "CA"),
("California", "CA"),
("CALIFORNIA", "CA"),
("new york", "NY"),
("New York", "NY"),
("NEW YORK", "NY"),
("texas", "TX"),
("Texas", "TX"),
("florida", "FL"),
("Florida", "FL"),
# Common variations should be handled
("calif", "CA"),
("cal", "CA"),
("fla", "FL"),
("ny", "NY"),
("n.y.", "NY"),
("tex", "TX"),
("penn", "PA"),
("mass", "MA"),
("wash", "WA"),
("washington state", "WA"),
("d.c.", "DC"),
("dc", "DC"),
("washington dc", "DC"),
("district of columbia", "DC"),
# Edge cases with whitespace
(" california ", "CA"),
(" NY ", "NY"),
(" texas ", "TX"), # tabs
# Invalid/unrecognized values should be returned as-is
("invalid_state", "invalid_state"),
("123", "123"),
("XY", "XY"), # Not a valid state abbreviation
("some random text", "some random text"),
# All 50 states + DC (spot check a few more)
("alabama", "AL"),
("alaska", "AK"),
("arizona", "AZ"),
("wyoming", "WY"),
("hawaii", "HI"),
("vermont", "VT"),
],
)
def test_normalize_state_to_abbreviation(self, state_value, expected):
from src.utils.string_utils import normalize_state_to_abbreviation
result = normalize_state_to_abbreviation(state_value)
assert result == expected
def test_all_valid_abbreviations_preserved(self):
"""Test that all valid 2-letter state abbreviations are preserved when uppercase"""
from src.utils.string_utils import normalize_state_to_abbreviation
valid_abbreviations = [
"AL",
"AK",
"AZ",
"AR",
"CA",
"CO",
"CT",
"DE",
"FL",
"GA",
"HI",
"ID",
"IL",
"IN",
"IA",
"KS",
"KY",
"LA",
"ME",
"MD",
"MA",
"MI",
"MN",
"MS",
"MO",
"MT",
"NE",
"NV",
"NH",
"NJ",
"NM",
"NY",
"NC",
"ND",
"OH",
"OK",
"OR",
"PA",
"RI",
"SC",
"SD",
"TN",
"TX",
"UT",
"VT",
"VA",
"WA",
"WV",
"WI",
"WY",
"DC",
]
for abbrev in valid_abbreviations:
result = normalize_state_to_abbreviation(abbrev)
assert result == abbrev, f"Failed for abbreviation: {abbrev}"
# Also test lowercase versions
result_lower = normalize_state_to_abbreviation(abbrev.lower())
assert (
result_lower == abbrev
), f"Failed for lowercase abbreviation: {abbrev.lower()}"
def test_case_insensitivity(self):
"""Test that the function handles various case combinations"""
from src.utils.string_utils import normalize_state_to_abbreviation
test_cases = [
("california", "CA"),
("California", "CA"),
("CALIFORNIA", "CA"),
("CaLiFoRnIa", "CA"),
("new york", "NY"),
("New York", "NY"),
("NEW YORK", "NY"),
("NeW yOrK", "NY"),
]
for input_state, expected in test_cases:
result = normalize_state_to_abbreviation(input_state)
assert result == expected, f"Failed for: {input_state}"
def test_edge_cases(self):
"""Test edge cases and boundary conditions"""
from src.utils.string_utils import normalize_state_to_abbreviation
# Test with just whitespace
assert normalize_state_to_abbreviation(" ") == " "
# Test with mixed valid/invalid content
assert normalize_state_to_abbreviation("california123") == "california123"
# Test very long strings
long_string = "this is a very long string that is not a state"
assert normalize_state_to_abbreviation(long_string) == long_string
# Test single characters
assert normalize_state_to_abbreviation("C") == "C"
assert normalize_state_to_abbreviation("A") == "A"