Files
doczyai-pipelines/fieldExtraction/tests/string_utils_test.py
T
Alex Galarce 4844dcfd50 Merged in feature/tin-npi-prompt-formatting (pull request #490)
Collect TIN/NPI/Name page-by-page and run a second pass for IS_GROUP

* configure first pass: no providers marked IS_GROUP.  Allows simplification of prompt

* Add group provider identification logic in second pass

* group identification: don't take 20% of pages.  Take first 3 and last 3

* Refine group provider identification logic to use pipes for JSON formatting and improve error handling

* fix mypy error for `identify_group_provider`

* Merge remote-tracking branch 'origin/main' into feature/tin-npi-prompt-formatting

* Add "PROV_INFO_JSON" to investment column order

* Refactor group provider identification to use signature page extraction and fix JSON format

* move signature page extraction to `string_utils.py` and add unit tests for it

* Update identify_group_provider to return provider name along with TIN and NPI in JSON format

* Update identify_group_provider to always use group-provided name when available

* move out GROUP_TIN_NPI_TEMPLATE to `investment_prompts.py`

* Remove deprecated PROV_GROUP_TIN_CHECK and PROV_GROUP_NPI_CHECK prompts

* Move out provider info fields from `one_to_one_funcs` to `tin_npi_funcs`

* isort

* remove debug print statements

* Remove debug print statements from provider info functions

* add note to regex_utils.py about where functions are used

* Add page_key_sort function and corresponding tests for sorting page keys

* Refactor identify_group_provider to sort pages and avoid duplicates in extracted sections

* Remove debug print statements from identify_group_provider function

* Merge remote-tracking branch 'origin/main' into feature/tin-npi-prompt-formatting

* extract_signature_page function: prioritize Textract marker for signatures and fallback to keyword search

* fix mypy error

* Update test cases to reflect changes in signature extraction logic


Approved-by: Katon Minhas
2025-04-25 20:28:52 +00:00

274 lines
12 KiB
Python

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
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):
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"}),
])
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')
def test_valid_json(self, mock_invoke_claude):
dict_string = '{"key": "value"}'
filename = "test_file.txt"
result = secondary_string_to_dict(dict_string, filename)
assert result == {"key": "value"}
mock_invoke_claude.assert_not_called()
def test_invalid_json_fixed_by_replacing_quotes(self, mock_invoke_claude):
dict_string = "{'key': 'value'}"
filename = "test_file.txt"
result = secondary_string_to_dict(dict_string, filename)
assert result == {"key": "value"}
mock_invoke_claude.assert_not_called()
def test_invalid_json_fixed_by_llm(self, mock_invoke_claude):
mock_invoke_claude.return_value = '{"key": "value"}'
dict_string = "{key: value}"
filename = "test_file.txt"
result = secondary_string_to_dict(dict_string, filename)
assert result == {"key": "value"}
mock_invoke_claude.assert_called_once()
def test_invalid_json_failed_by_llm(self, mock_invoke_claude):
mock_invoke_claude.side_effect = Exception("LLM call failed")
dict_string = "{key: value}"
filename = "test_file.txt"
result = secondary_string_to_dict(dict_string, filename)
assert result == {}
mock_invoke_claude.assert_called_once()
def test_no_dict_found(self, mock_invoke_claude):
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)
assert result == {}
mock_invoke_claude.assert_called_once()
@pytest.fixture
def mock_secondary_string_to_dict(self, mocker):
return mocker.patch.object(utils.string_utils, "secondary_string_to_dict")
def test_valid_input(self, mock_secondary_string_to_dict):
mock_secondary_string_to_dict.side_effect = [
{"key1": "value1"},
{"key2": "value2"},
{"key3": "value3"},
]
string_dict = {
"1": "[{'key1': 'value1'}, {'key2': 'value2'}]",
"2": "[{'key3': 'value3'}]",
}
filename = "test_file.txt"
expected_output = [
{"key1": "value1", "page_num": "1"},
{"key2": "value2", "page_num": "1"},
{"key3": "value3", "page_num": "2"},
]
result = primary_string_to_dict(string_dict, filename)
assert result == expected_output
assert mock_secondary_string_to_dict.call_count == len(expected_output)
def test_invalid_input_no_dicts(self, mock_secondary_string_to_dict):
mock_secondary_string_to_dict.return_value = {}
string_dict = {
"1": "[This is not a dictionary]",
"2": "[Still not a dictionary]",
}
filename = "test_file.txt"
expected_output = []
result = primary_string_to_dict(string_dict, filename)
assert result == expected_output
assert mock_secondary_string_to_dict.call_count == len(expected_output)
def test_empty_input(self, mock_secondary_string_to_dict):
string_dict = {}
filename = "test_file.txt"
expected_output = []
result = primary_string_to_dict(string_dict, filename)
assert result == expected_output
mock_secondary_string_to_dict.assert_not_called()
def test_malformed_input(self, mock_secondary_string_to_dict):
mock_secondary_string_to_dict.side_effect = [
{"key1": "value1"},
{"key2": "value2"},
]
string_dict = {
"1": "[{'key1': 'value1'}, malformed]",
"2": "[{'key2': 'value2'}, <<<invalid>>>]",
}
filename = "test_file.txt"
expected_output = [
{"key1": "value1", "page_num": "1"},
{"key2": "value2", "page_num": "2"},
]
result = primary_string_to_dict(string_dict, filename)
assert result == expected_output
assert mock_secondary_string_to_dict.call_count == 2
def test_multiple_pages(self, mock_secondary_string_to_dict):
mock_secondary_string_to_dict.side_effect = [
{"key1": "value1"},
{"key2": "value2"},
{"key3": "value3"},
{"key4": "value4"},
]
string_dict = {
"1": "[{'key1': 'value1'}, {'key2': 'value2'}]",
"2": "[{'key3': 'value3'}, {'key4': 'value4'}]",
}
filename = "test_file.txt"
expected_output = [
{"key1": "value1", "page_num": "1"},
{"key2": "value2", "page_num": "1"},
{"key3": "value3", "page_num": "2"},
{"key4": "value4", "page_num": "2"},
]
result = primary_string_to_dict(string_dict, filename)
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),
])
def test_contains_reimbursement(self, text, page, expected):
result = contains_reimbursement(text, page)
assert result == expected
def test_invalid_input_type(self, capsys):
text = ["This is a list, not a dictionary or string."]
page = "1"
result = contains_reimbursement(text, page)
assert result is False
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),
])
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": "Another signature is found here."},
"test_file.txt",
{"1": "This page has a signature.", "2": "Another signature is found here."}
),
(
{"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": "This page has a signature.", "2": "Signature is mentioned here too."},
"test_file.txt",
{"1": "This page has a signature.", "2": "Signature is mentioned here too."}
),
(
{},
"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
])
def test_page_key_sort(self, page_key, expected):
result = page_key_sort(page_key)
assert result == expected
def test_page_key_sort_with_sorting(self):
keys = ["10", "IV", "2", "A", "1", "B", "Z"]
expected_sorted_keys = ["1", "2", "10", "A", "B", "IV", "Z"]
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),
])
def test_count_reimbursements_in_exhibit(self, exhibit_text, expected_count):
result = count_reimbursements_in_exhibit(exhibit_text)
assert result == expected_count