Files
doczyai-pipelines/fieldExtraction/src/utils.py
T
Chris Bull 54a62944aa Merged in QCQA_Integration (pull request #311)
QCQA Integration

* Merged in bugfix/npi_hotfix (pull request #305)

double filter for duplicates

* double filter for duplicates


Approved-by: Katon Minhas

* Merged in chore/npi (pull request #309)

npi hotfix into main

* npi hotfix into main

* npi hotfix into main


Approved-by: Alex Galarce

* Merged in chore/irs (pull request #310)

irs hotfix into main

* irs hotfix into main

* pass top sheet to irs hotfix func

* docstring updates


Approved-by: Alex Galarce

* slight fix to methodology short fix

* Merged in bugfix/all_cnc_hotfixes (pull request #302)

DRAFT: Bugfix/all cnc hotfixes

* Pipeline fix - health plan state

* Update for prompt vs non-prompt fixes

* Revert utils.find_regex_matches()

* Remove prints

* Non-prompt run

* Health Plan State, IRS, NPI, LOB, Lesser, Prov 2, Default, Rate Standard, Contract Effective Date, Term Group

* Merged in bugfix/agreement_name (pull request #294)

contract title fixed

* contract title fixed

* Merged bugfix/all_cnc_hotfixes into bugfix/agreement_name

Approved-by: Katon Minhas

* All hotfixes added

* Fixed utils

* fix for clean_contract_effective_date
* Merged in default-fix (pull request #296)

moved default funcs to the top, dropped intermediate columns

* moved default funcs to the top, dropped intermediate columns

Approved-by: Katon Minhas

* quick fix to dropping extra columns

* Merged in bugfix/effective_date_meridian (pull request #297)

fixed effective date for meridian contracts

* fixed effective date for meridian contracts

Approved-by: Kat…
* Merged in hotfix/irs_tweak (pull request #303)

returning None instead on str(N/A)

* returning None instead on str(N/A)


Approved-by: Katon Minhas

* Merged in bugfix/npi_hotfix (pull request #305)

double filter for duplicates

* double filter for duplicates


Approved-by: Katon Minhas

* Merged in bugfix/all_cnc_hotfixes (pull request #302)

DRAFT: Bugfix/all cnc hotfixes

* Pipeline fix - health plan state

* Update for prompt vs non-prompt fixes

* Revert utils.find_regex_matches()

* Remove prints

* Non-prompt run

* Health Plan State, IRS, NPI, LOB, Lesser, Prov 2, Default, Rate Standard, Contract Effective Date, Term Group

* Merged in bugfix/agreement_name (pull request #294)

contract title fixed

* contract title fixed

* Merged bugfix/all_cnc_hotfixes into bugfix/agreement_name

Approved-by: Katon Minhas

* All hotfixes added

* Fixed utils

* fix for clean_contract_effective_date
* Merged in default-fix (pull request #296)

moved default funcs to the top, dropped intermediate columns

* moved default funcs to the top, dropped intermediate columns

Approved-by: Katon Minhas

* quick fix to dropping extra columns

* Merged in bugfix/effective_date_meridian (pull request #297)

fixed effective date for meridian contracts

* fixed effective date for meridian contracts

Approved-by: Kat…
* Merged in hotfix/irs_tweak (pull request #303)

returning None instead on str(N/A)

* returning None instead on str(N/A)


Approved-by: Katon Minhas

* Merged in bugfix/npi_hotfix (pull request #305)

double filter for duplicates

* double filter for duplicates


Approved-by: Katon Minhas

* Merged in chore/npi (pull request #309)

npi hotfix into main

* npi hotfix into main

* npi hotfix into main

Approved-by: Alex Galarce

* Merged in chore/irs (pull request #310)

irs hotfix into main

* irs hotfix into main

* pass top sheet to irs hotfix func

* docstring updates


Approved-by: Alex Galarce

* Merged in bugfix/clean_short_methodology (pull request #312)

changed regex pattern in get_short_methodology

* changed regex pattern in


Approved-by: Katon Minhas

* Merged in hotfix/filename_fix (pull request #314)

filename corrected

* filename corrected

* Merged main into hotfix/filename_fix


Approved-by: Katon Minhas

* Merge remote-tracking branch 'remotes/origin/main' into HEAD

* rearranged qcqa scripts/funcs and prep for merge w main, moved lots of stuff to postprocess

* slight change to icm_number_fix

* transitioned some qcqa funcs to postprocessing, added fields to qcqa report

* Merged main into QCQA_Integration

* reorganization of qa qc helpers, consolidate_output, qa_qc from Katon's feedback

* adhoc and at scale qcqa ran successfully

* fix to consolidate_output

* Merge remote-tracking branch 'remotes/origin/main' into QCQA_Integration

* Merged main into QCQA_Integration

* small changes to npi_check, changed name of config.CONTRACT_FILE_NAME to config.FILE_NAME_COLUMN


Approved-by: Katon Minhas
2024-12-06 17:27:42 +00:00

457 lines
15 KiB
Python

import os
import re
import pandas as pd
import shutil
import time
import sys
import numpy as np
from collections import defaultdict
import config
from botocore.exceptions import ClientError
from io import StringIO
class InvalidDateException(Exception):
pass
def read_local(file_path):
# Check if the file is a text file
if os.path.isfile(file_path) and file_path.endswith(".txt"):
try:
# First attempt to open the file with UTF-8 encoding
with open(file_path, "r", encoding="utf-8") as file:
file_contents = file.read()
return file_contents
except UnicodeDecodeError:
# If UTF-8 fails, try reading the file with ANSI encoding
try:
with open(file_path, "r", encoding="cp1252") as file:
file_contents = file.read()
return file_contents
except UnicodeDecodeError:
# If ANSI also fails, log an error message or handle it accordingly
print(f"Failed to decode {file_path} with UTF-8 and cp1252 encodings.")
if os.path.isfile(file_path) and file_path.endswith(".csv"):
try:
df = pd.read_csv(file_path)
return df
except Exception:
print('Failed to read .csv file.')
if os.path.isfile(file_path) and file_path.endswith(".xlsx"):
try:
df = pd.read_excel(file_path)
return df
except Exception:
print('Failed to read .xlsx file.')
def list_s3_files(prefix=config.S3_PREFIX, bucket=config.S3_BUCKET):
"""List all files in an S3 folder."""
response = config.S3_CLIENT.list_objects_v2(Bucket=bucket, Prefix=prefix)
return [item["Key"] for item in response.get("Contents", [])]
def read_s3():
s3_client = config.S3_CLIENT
bucket = config.S3_BUCKET
prefix = config.S3_PREFIX
file_list = []
continuation_token = None
while True:
if continuation_token:
response = s3_client.list_objects_v2(
Bucket=bucket, Prefix=prefix, ContinuationToken=continuation_token
)
else:
response = s3_client.list_objects_v2(Bucket=bucket, Prefix=prefix)
file_list += [
obj["Key"]
for obj in response.get("Contents", [])
if not obj["Key"].endswith("/")
]
if response.get("IsTruncated"):
continuation_token = response.get("NextContinuationToken")
else:
break
contract_list = sorted(file_list)
files = {}
for contract in contract_list:
print(f"Reading {contract}")
try:
data = s3_client.get_object(Bucket=bucket, Key=contract)
if contract.endswith('.txt'):
contents = data["Body"].read()
context = contents.decode("utf-8")
path, filename = os.path.split(contract)
files[filename] = context
except:
pass
return files
def read_s3_csv(csv_path):
s3_client = config.S3_CLIENT
s3_parts = csv_path[5:].split("/", 1)
bucket = s3_parts[0]
prefix = s3_parts[1] if len(s3_parts) > 1 else ""
try:
for obj in s3_client.list_objects_v2(Bucket=bucket, Prefix=prefix)["Contents"]:
key = obj["Key"]
if key.endswith(".csv"):
response = s3_client.get_object(Bucket=bucket, Key=key)
df = pd.read_csv(StringIO(response["Body"].read().decode("utf-8")))
return df
except ClientError as e:
print(f"Error listing S3 objects: {e}")
def read_input(local_path=config.LOCAL_PATH):
if config.READ_MODE == "local":
files = {}
for file in os.listdir(local_path):
full_path = os.path.join(local_path, file)
file_text = read_local(full_path)
files[file] = file_text
return files
elif config.READ_MODE == "s3":
return read_s3()
def read_input_csv():
if config.DF_READ_MODE == "local":
ac_df = read_local(config.AC_DF) if config.AC_DF else None
b_df = read_local(config.B_DF) if config.B_DF else None
elif config.DF_READ_MODE == "s3":
ac_df = read_s3_csv(config.AC_DF) if config.AC_DF else None
b_df = read_s3_csv(config.B_DF) if config.B_DF else None
return ac_df, b_df
def consolidate_individual(input_folder="results", output_folder="output"):
dfs = []
for filename in os.listdir(input_folder):
if filename.endswith(".csv"):
filepath = os.path.join(input_folder, filename)
dfs.append(pd.read_csv(filepath))
# Remove temp folder
if input_folder == "temp" and os.path.exists(input_folder):
shutil.rmtree(input_folder)
consolidated_df = pd.concat(dfs, ignore_index=True)
# Write output
version = 1
existing_files = [
filename
for filename in os.listdir(output_folder)
if filename.startswith(f"consolidated_results_{config.TODAY}")
]
if existing_files:
versions = [
int(file.split("_v")[1].split(".")[0])
for file in existing_files
if "_v" in file
]
if versions:
version = max(versions) + 1
filename = f"consolidated_results_{config.TODAY}_v{version}.csv"
consolidated_df.to_csv(os.path.join(output_folder, filename))
def preprocess_text_file(file_path):
# Attempt to open the file with UTF-8 encoding first
try:
with open(file_path, "r", encoding="utf-8") as file:
text = file.read()
except UnicodeDecodeError:
# If UTF-8 fails, try reading the file with ANSI (cp1252) encoding
try:
with open(file_path, "r", encoding="cp1252") as file:
text = file.read()
except UnicodeDecodeError:
# If ANSI also fails, log an error message or handle it accordingly
print(f"Failed to decode {file_path} with UTF-8 and cp1252 encodings.")
return []
# Split the text into pages based on a specific marker
pages = re.split(r"Start of Page No\. = \d+", text)
return pages
def format_td_check(td_dicts, dont_include_list):
final_str = ""
dict_count = 1
for td_dict in td_dicts:
final_str += str(dict_count) + ". "
for k in td_dict.keys():
if k not in dont_include_list:
final_str += k + ": " + td_dict[k] + ", "
final_str += "\n"
dict_count += 1
return final_str
def contains_reimbursement(text, page="1"):
if isinstance(text, dict):
return page.isdigit() and (
"%" in text[page]
or "$" in text[page]
or "percent " in text[page]
or "compensation schedule" in text[page].lower()
or "reimbursement schedule" in text[page].lower()
)
elif isinstance(text, str):
return (
"%" in text
or "$" in text
or "percent " in text
or "compensation schedule" in text.lower()
or "reimbursement schedule" in text.lower()
)
else:
print("contains_reimbursement - Invalid data type")
def split_and_write_csv(
df, n, column_name, output_dir=config.CONSOLIDATED_OUTPUT_DIRECTORY
):
"""
Splits the DataFrame into N sub-DataFrames based on unique values in a specified column
and writes each sub-DataFrame to a separate CSV file. Each sub-DataFrame will contain
roughly the same number of unique values from the specified column.
Args:
df (pd.DataFrame): The DataFrame to split.
n (int): The number of sub-DataFrames to create.
column_name (str): The column to split the DataFrame by.
output_dir (str): The directory to save the CSV files to.
"""
# Create the directory if it does not exist
if not os.path.exists(output_dir):
os.makedirs(output_dir)
# Get unique values and shuffle them to randomize distribution if needed
unique_values = np.array(df[column_name].unique())
np.random.shuffle(unique_values)
# Split the unique values into N parts
value_groups = np.array_split(unique_values, n)
# Loop through each group and filter DataFrame by the values in each group
for i, values in enumerate(value_groups, 1):
# Filter df for the values in this group
sub_df = df[df[column_name].isin(values)]
# Create a filename for each sub-DataFrame
filename = f"{config.BATCH_ID}-{i}.csv"
path = os.path.join(output_dir, filename)
# Write the sub-DataFrame to a CSV file
sub_df.to_csv(path, index=False)
print(f"Written {path}")
def contains_lol(text, page='1'):
if isinstance(text, dict):
return page.isdigit() and (
re.search('les+e*o*r of+', text[page].lower())
)
elif isinstance(text, str):
return (
re.search('les+e*o*r of+', text.lower())
)
else:
print("contains_lol - Invalid data type")
def filter_already_processed(input_dict, input_dict_b):
already_processed_ac, already_processed_b = [], []
for folder_name in os.listdir(config.OUTPUT_DIRECTORY):
# AC
if config.AC_RESULTS_NAME in os.listdir(
os.path.join(config.OUTPUT_DIRECTORY, folder_name)
):
already_processed_ac.append(folder_name + ".txt")
# B
if config.B_RESULTS_NAME in os.listdir(
os.path.join(config.OUTPUT_DIRECTORY, folder_name)
):
already_processed_b.append(folder_name + ".txt")
input_dict_ac = {
key: input_dict[key]
for key in input_dict.keys()
if key not in already_processed_ac
}
input_dict_b = {
key: input_dict_b[key]
for key in input_dict_b.keys()
if key not in already_processed_b
}
return input_dict_ac, input_dict_b
def is_empty(value):
if pd.isna(value):
return True
else:
empty_values = [None, "", "N/A", "NA", "null", "none", "NaN", np.nan, "nan"]
return value in empty_values
def find_regex_matches(
pattern: str, text_dict: dict
) -> tuple[list, list]: # TODO: Write few unit tests
"""Helper function to find matches and list of pages of a given regex pattern in text_dict.
Args:
pattern(str): A particular regex match that with a pattern.
text_dict (dict): Dictionary keyed by string page-number and valued by actual textract output page
Returns:
matches(list): List of match(s)
page_list(list): List of pages where the match was found
"""
matches = []
page_list = []
for page, text in text_dict.items():
found = re.findall(pattern, text)
if found:
matches.extend(found) # make it append
page_list.append(page)
return matches, page_list
# matches = defaultdict(list)
# for page, text in text_dict.items():
# matches_found = re.findall(pattern, text)
# if matches_found:
# matches[page].extend(matches_found)
# return list(matches.values()), list(matches.keys())
def chunk_selected_pages(text_dict: dict[str, str], page_list: list) -> str:
"""Combines chunks for pages with regex matches for IRS, NPI, etc.
Args:
text_dict (dict[str, str]): A dictionary keyed by string page number and valued
by string page contents
page_list (str): List of pages to be chunked for context
Returns:
context(str): corresponding chunks of text (input strings delimited by newlines).
"""
page_list = sorted([int(page_str) for page_str in set(page_list)])
context = "\n".join([text_dict[str(page_num)] for page_num in page_list])
return context
def add_hyphen_if_needed(input_str):
"""Checks for specific patterns in the input string and modifies it by adding
a '0' at the start and a hyphen at the 3rd index, or just adds a hyphen at
the 3rd index if it's a 9-digit number.
If the input string is exactly 8 characters long, it adds a '0' at the start
and inserts a hyphen at the 3rd position. If the string matches the pattern
of a 9-digit number (exactly 9 digits), it inserts a hyphen at the 3rd index.
If the input string matches the pattern 'x-xxxxxxx', it adds '0' at the start.
All conditions will return a string in the format 'xx-xxxxxxx'.
Args:
- input_str (str): The input string (IRS).
Returns:
- str: The modified string in the format 'xx-xxxxxxx'.
Example:
- '1-2345678''01-2345678'
- '12345678''01-2345678'
- '123456789''12-3456789'
"""
# Check if the string matches the pattern 'x-xxxxxxx' (1 character, hyphen, 7 characters)
if re.fullmatch(r"\d-\d{7}", input_str):
# Add '0' at the start
input_str = '0' + input_str
# Check if the string is exactly 'xxxxxxxx' (8 characters)
elif bool(re.fullmatch(r"\b\d{8}\b", input_str)):
# Add '0' at the start and insert a hyphen at the 3rd index
input_str = '0' + input_str
input_str = input_str[:2] + '-' + input_str[2:]
# If the string matches the pattern of a 9-digit number, add a hyphen at the 3rd index
elif bool(re.fullmatch(r"\b\d{9}\b", input_str)):
input_str = input_str[:2] + '-' + input_str[2:]
# Ensure the output is in the format 'xx-xxxxxxx'
if not bool(re.fullmatch(r"\b\d{2}-\d{7}\b", input_str)):
input_str = None
return input_str
def convert_to_us_date_format(date_str: str) -> str:
"""
Input date string is expected to be in the format YYYY-MM-DD
Ensure the date is in MM/DD/YYYY format. If the month is greater than 12, swap the month and day.
If the input is `N/A` or some variant (defined by is_empty), InvalidDateException will be raised.
Args:
date_str (str): The date string to be formatted. We expect it to come in as YYYY-MM-DD format (note the hyphens as well)
Returns:
str: The date string formatted as MM/DD/YYYY.
Raises:
InvalidDateException: If the date string is not in the expected format.
"""
if not isinstance(date_str, str):
raise TypeError(f"Date must be a string, got: {type(date_str)}")
if is_empty(date_str) or re.fullmatch(r"\d{4}-\d{1,2}-\d{1,2}", date_str) is None:
raise InvalidDateException(f"Invalid date format: {date_str}")
# Split the date string into year, month, and day
year, month, day = date_str.split("-")
year_int = int(year)
month_int = int(month)
day_int = int(day)
if year_int == 0 or month_int == 0 or day_int == 0:
raise InvalidDateException(f"Invalid date format: {date_str}")
if month_int > 12 and 0 < day_int <= 31:
# Swap the month and day if the month is greater than 12
month_int, day_int = day_int, month_int
if 0 < month_int <= 12 and 0 < day_int <= 31:
# Return the formatted date in MM/DD/YYYY format
year, month, day = str(year_int).zfill(4), str(month_int).zfill(2), str(day_int).zfill(2)
return "/".join([month, day, year])
else:
# Raise an exception if the month or day is out of range
raise InvalidDateException(f"Invalid date format: {date_str}")
def remove_unnamed_columns(df):
return df[[col for col in df.columns if 'Unnamed' not in col]]
def remove_txt_extension(filename):
if isinstance(filename, str) and filename.lower().endswith(".txt"):
return filename[:-4]
return filename