Files
doczyai-pipelines/fieldExtraction/src/qa_qc_helpers.py
T

545 lines
22 KiB
Python
Raw Normal View History

import os
import pandas as pd
import boto3
from botocore.exceptions import ClientError
import re
from datetime import datetime
from valid import STATE_MAP, AC_IND_LANG_COLS, B_IND_LANG_COLS
import utils
import preprocessing_funcs
import config
from openpyxl import Workbook
from openpyxl.utils.dataframe import dataframe_to_rows
def detect_date_anomalies(series, field_name, is_merged=False):
total_count = len(series)
unique_values = series.fillna("").nunique()
date_fields = [
"CONTRACT_BASE_EFFECTIVE_DT",
"CONTRACT_EFFECTIVE_DT",
"CONTRACT_EFFECTIVE_DT_SIGNATORY",
"CONTRACT_TERMINATION_DT",
"CONTRACT_SIGNATORY_DT",
]
date_pattern = r"(\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{2,4}[/-]\d{1,2}[/-]\d{1,2})"
date_like_count = 0
if pd.api.types.is_string_dtype(series) or pd.api.types.is_object_dtype(series):
series = series.astype(str)
if field_name in date_fields:
date_like_count = series.str.count(date_pattern).sum()
else:
date_like_count = series.str.count(r"\d{1,2}/\d{1,2}/\d{2,4}").sum()
data_type = "float64" if pd.api.types.is_float_dtype(series) else "string"
dataset = "Merged DataFrame" if is_merged else "Original DataFrame"
result = {
f"Total entries ({dataset})": total_count,
f"Unique values ({dataset})": unique_values,
f"Date-like entries ({dataset})": date_like_count,
}
if is_merged:
result[f"Data type ({dataset})"] = data_type
return result
def tin_check(tin: str) -> tuple[str, bool]:
pattern = r'\b\d{2}-\d{7}\b'
if not isinstance(tin, str):
try:
tin = str(tin)
except TypeError:
check = False
return tin, check
check=bool(re.match(pattern, tin))
return tin, check
def npi_check(npi: str) -> tuple[str, bool]:
pattern = r'^[1-2]\d{9}$'
if not isinstance(npi, str):
try:
npi = str(npi)
except TypeError:
check = False
return npi, check
check = bool(re.match(pattern, npi))
return npi, check
def date_format_check(date: str) -> tuple:
format = '%m/%d/%Y'
try:
dt = datetime.strptime(date, format)
date = dt.strftime('%m/%d/%Y')
check = True
except ValueError:
date = date
check = False
return date, check
def yn_check(yn: str) -> tuple[str, bool]:
if not isinstance(yn, str):
try:
yn = str(yn)
except TypeError:
check = False
return yn, check
return yn, (yn == 'Y' or yn == 'N')
def state_check(state: str) -> tuple[str, bool]:
if not isinstance(state, str):
try:
state = str(state)
except TypeError:
check = False
return state, check
if state in STATE_MAP.keys():
state = STATE_MAP[state]
check = True
elif state.strip().title() in STATE_MAP.values():
check = True
else:
check = False
return state, check
def pages_pagenum_check(pages: str, pagenum: str) -> bool:
if not isinstance(pages, str):
try:
pages = str(pages)
except TypeError:
check = False
return check
elif not isinstance(pagenum, str):
try:
pagenum = str(pagenum)
except TypeError:
check = False
return check
else:
check = (pagenum < pages)
return check
def perform_qc_qa_tests_both(ac_df, b_df, merged_df, input_dict, processed_files):
file_name_column = config.FILE_NAME_COLUMN
total_input_files = len(input_dict) if input_dict else 0
processed_ac_files = len(
[f for f in processed_files if f in ac_df[file_name_column].values]
)
processed_b_files = len(
[f for f in processed_files if f in b_df[file_name_column].values]
)
failed_ac_files = (
total_input_files - processed_ac_files if total_input_files > 0 else 0
)
failed_b_files = (
total_input_files - processed_b_files if total_input_files > 0 else 0
)
yn_cols_b = [col for col in b_df.columns if '(Y/N)' in col]
yn_cols_ac = [col for col in ac_df.columns if '(Y/N)' in col]
general_stats = {
"Total Number of Contracts processed": total_input_files,
"# of contracts successfully processed for AC fields": processed_ac_files,
"# of contracts successfully processed for B fields": processed_b_files,
"# of contracts failed for AC fields": failed_ac_files,
"# of contracts failed for B fields": failed_b_files,
"# of rows for AC run": len(ac_df),
"# of rows for B run": len(b_df),
"Total rows in merged DataFrame": len(merged_df),
"Unique Filenames in AC DataFrame": ac_df[file_name_column].nunique(),
"Unique Filenames in B DataFrame": b_df[file_name_column].nunique(),
"Unique Filenames in merged DataFrame": merged_df[file_name_column].nunique(),
"Contracts with all AC fields missing": merged_df[
[col for col in ac_df.columns if col != file_name_column]
]
.isnull()
.all(axis=1)
.sum(),
"Contracts with all B fields missing": merged_df[
[col for col in b_df.columns if col != file_name_column]
]
.isnull()
.all(axis=1)
.sum(),
"# of duplicate rows for AC run": ac_df.duplicated().sum(),
"# of duplicate rows for B run": b_df.duplicated().sum(),
"Number of duplicate rows in merged DataFrame": merged_df.duplicated().sum(),
"# of fields for AC run" : len(ac_df.columns),
"# of fields for B run" : len(b_df.columns),
"# of incorrectly formatted Provider Group TINs" : ac_df["IRS #"].apply(lambda x: not tin_check(x)[1] if not utils.is_empty(x) else False).sum(),
"# of incorrectly formatted Provider Group NPIs" : ac_df["NPI (10-digits)"].apply(lambda x: not npi_check(x)[1] if not utils.is_empty(x) else False).sum(),
"# of incorrectly formatted Provider Group Signatory TINs": ac_df["PROV_GROUP_TIN_SIGNATORY"].apply(lambda x: not tin_check(x)[1] if not utils.is_empty(x) else False).sum(),
"# of incorrectly formatted Provider TINs (other)" : ac_df["PROV_TIN_OTHER"].apply(lambda x: not tin_check(x)[1] if not utils.is_empty(x) else False).sum(),
"# of incorrectly formatted Provider NPIs (other)": ac_df["PROV_NPI_OTHER"].apply(lambda x: not npi_check(x)[1] if not utils.is_empty(x) else False).sum(),
"# of incorrect Health Plan States" : ac_df['Health Plan State'].apply(lambda x: not state_check(x)[1] if not utils.is_empty(x) else False).sum(),
"# of rows with Pages greater than Page Num" : merged_df.apply(lambda row: pages_pagenum_check(row['Pages'], row['Page_Num']) if (not utils.is_empty(row['Pages']) and utils.is_empty(row['Page_Num'])) else False, axis=1).sum(),
"# of Payer Names found in Notice to Provider Name" : ac_df.apply(lambda row: row['PAYER NAME'] in row['Notice to Provider Name'] if not utils.is_empty(row['Notice to Provider Name']) else False, axis = 1).sum(),
"# of invalid Y/N values for B run" : b_df[yn_cols_b].apply(lambda x: yn_check(x)[1], axis = 1).sum().sum(),
"# of invalid Y/N values for AC run" : ac_df[yn_cols_ac].apply(lambda x: yn_check(x)[1], axis = 1).sum().sum(),
"# of invalid Termination Dates" : ac_df['Termination Date'].apply(lambda x: not date_format_check(x) if not utils.is_empty(x) else x).sum(),
"# of invalid Conract Effective Dates" : ac_df['Contract Effective Date'].apply(lambda x: not date_format_check(x) if not utils.is_empty(x) else x).sum()
}
ac_stats = {}
for field in [col for col in ac_df.columns if col != file_name_column]:
missing_contracts = merged_df[merged_df[field].isnull()][
file_name_column
].nunique()
ac_stats[field] = {
"Contracts missing this field (Merged DataFrame)": missing_contracts,
**detect_date_anomalies(ac_df[field], field, is_merged=False),
**detect_date_anomalies(merged_df[field], field, is_merged=True),
}
b_stats = {}
for field in [col for col in b_df.columns if col != file_name_column]:
missing_contracts = (
merged_df.groupby(file_name_column)[field]
.apply(lambda x: x.isnull().all())
.sum()
)
contracts_with_missing = (
merged_df.groupby(file_name_column)[field]
.apply(lambda x: x.isnull().any())
.sum()
)
total_missing = merged_df[field].isnull().sum()
b_stats[field] = {
"Contracts missing all entries (Merged DataFrame)": missing_contracts,
"Contracts with at least one missing entry (Merged DataFrame)": contracts_with_missing,
"Total missing values (Merged DataFrame)": total_missing,
**detect_date_anomalies(b_df[field], field, is_merged=False),
**detect_date_anomalies(merged_df[field], field, is_merged=True),
}
return general_stats, ac_stats, b_stats
def perform_qc_qa_tests_ac(ac_df, input_dict, processed_files):
file_name_column = config.FILE_NAME_COLUMN
total_input_files = len(input_dict) if input_dict else 0
processed_ac_files = len(
[f for f in processed_files if f in ac_df[file_name_column].values]
)
failed_ac_files = (
total_input_files - processed_ac_files if total_input_files > 0 else 0
)
yn_cols_ac = [col for col in ac_df.columns if '(Y/N)' in col]
general_stats = {
"Total Number of Contracts processed": total_input_files,
"# of contracts successfully processed for AC fields": processed_ac_files,
"# of contracts failed for AC fields": failed_ac_files,
"# of rows for AC run": len(ac_df),
"Unique Filenames in AC DataFrame": ac_df[file_name_column].nunique(),
"Contracts with all AC fields missing": ac_df[
[col for col in ac_df.columns if col != file_name_column]
]
.isnull()
.all(axis=1)
.sum(),
"# of duplicate rows for AC run": ac_df.duplicated().sum(),
"# of fields for AC run" : len(ac_df.columns),
"# of incorrectly formatted Provider Group TINs" : ac_df["IRS #"].apply(lambda x: not tin_check(x)[1] if not utils.is_empty(x) else False).sum(),
"# of incorrectly formatted Provider Group NPIs" : ac_df["NPI (10-digits)"].apply(lambda x: not npi_check(x)[1] if not utils.is_empty(x) else False).sum(),
"# of incorrectly formatted Provider Group Signatory TINs": ac_df["PROV_GROUP_TIN_SIGNATORY"].apply(lambda x: not tin_check(x)[1] if not utils.is_empty(x) else False).sum(),
"# of incorrectly formatted Provider TINs (other)" : ac_df["PROV_TIN_OTHER"].apply(lambda x: not tin_check(x)[1] if not utils.is_empty(x) else False).sum(),
"# of incorrectly formatted Provider NPIs (other)": ac_df["PROV_NPI_OTHER"].apply(lambda x: not npi_check(x)[1] if not utils.is_empty(x) else False).sum(),
"# of incorrect Health Plan States" : ac_df['Health Plan State'].apply(lambda x: not state_check(x)[1] if not utils.is_empty(x) else False).sum(),
"# of Payer Names found in Notice to Provider Name" : ac_df.apply(lambda row: row['PAYER NAME'] in row['Notice to Provider Name'] if not utils.is_empty(row['Notice to Provider Name']) else False, axis = 1).sum(),
"# of invalid Y/N values for AC run" : ac_df[yn_cols_ac].apply(lambda x: yn_check(x)[1], axis = 1).sum().sum(),
"# of invalid Termination Dates" : ac_df['Termination Date'].apply(lambda x: not date_format_check(x) if not utils.is_empty(x) else x).sum(),
"# of invalid Conract Effective Dates" : ac_df['Contract Effective Date'].apply(lambda x: not date_format_check(x) if not utils.is_empty(x) else x).sum()
}
ac_stats = {}
for field in [col for col in ac_df.columns if col != file_name_column]:
missing_contracts = ac_df[ac_df[field].isnull()][file_name_column].nunique()
ac_stats[field] = {
"Contracts missing this field (Merged DataFrame)": missing_contracts,
**detect_date_anomalies(ac_df[field], field, is_merged=False),
}
return general_stats, ac_stats
def perform_qc_qa_tests_b(b_df, input_dict, processed_files):
file_name_column = config.FILE_NAME_COLUMN
total_input_files = len(input_dict) if input_dict else 0
processed_b_files = len(
[f for f in processed_files if f in b_df[file_name_column].values]
)
failed_b_files = (
total_input_files - processed_b_files if total_input_files > 0 else 0
)
yn_cols_b = [col for col in b_df.columns if '(Y/N)' in col]
general_stats = {
"Total Number of Contracts processed": total_input_files,
"# of contracts successfully processed for B fields": processed_b_files,
"# of contracts failed for B fields": failed_b_files,
"# of rows for B run": len(b_df),
"Unique Filenames in B DataFrame": b_df[file_name_column].nunique(),
"Contracts with all B fields missing": b_df[
[col for col in b_df.columns if col != file_name_column]
]
.isnull()
.all(axis=1)
.sum(),
"# of duplicate rows for B run": b_df.duplicated().sum(),
"# of fields for B run" : len(b_df.columns),
"# of rows with Pages greater than Page Num" : b_df.apply(lambda row: pages_pagenum_check(row['Pages'], row['Page_Num']), axis=1).sum(),
"# of invalid Y/N values for B run" : b_df[yn_cols_b].apply(lambda x: yn_check(x)[1], axis = 1).sum().sum()}
b_stats = {}
for field in [col for col in b_df.columns if col != file_name_column]:
missing_contracts = (
b_df.groupby(file_name_column)[field]
.apply(lambda x: x.isnull().all())
.sum()
)
contracts_with_missing = (
b_df.groupby(file_name_column)[field]
.apply(lambda x: x.isnull().any())
.sum()
)
total_missing = b_df[field].isnull().sum()
b_stats[field] = {
"Contracts missing all entries (B DataFrame)": missing_contracts,
"Contracts with at least one missing entry (B DataFrame)": contracts_with_missing,
"Total missing values (Merged DataFrame)": total_missing,
**detect_date_anomalies(b_df[field], field, is_merged=False),
}
return general_stats, b_stats
def get_table_stats(text_dict):
table_count, num_table_pages, rate_count = 0, 0, 0
table_pages, rate_pages, rates_more_than_10 = [], [], []
for page_num, page_text in text_dict.items():
if utils.contains_reimbursement(page_text, page_num):
rate_pages.append(page_num)
rates_on_page = sum(1 for char in page_text if char in {"$", "%"})
rate_count += rates_on_page
if rates_on_page >= 10:
rates_more_than_10.append(page_num)
table_texts = re.findall(
r"-------Table Start--------(.*?)-------Table End--------",
page_text,
re.DOTALL,
)
table_page_count = len(table_texts)
if table_page_count > 0:
table_count += table_page_count
num_table_pages += 1
table_pages.append(page_num)
return {
"Table Count": table_count,
"Table Page Count": num_table_pages,
"Table Pages": table_pages,
"Rate Count": rate_count,
"Rate Pages": rate_pages,
"Num Rate Pages": len(rate_pages),
">=10 Rate Pages": rates_more_than_10,
"Num >=10 Rates": len(rates_more_than_10),
}
def table_stats_qaqc(input_dict, ac_df, b_df, file_name_column) -> list[dict]:
table_stats = []
if input_dict:
for filename, content in input_dict.items():
filename_without_ext = utils.remove_txt_extension(filename).lower()
text_dict = preprocessing_funcs.split_text(content)
stats = get_table_stats(text_dict)
table_stats.append(
{
"Contract Name": filename,
"Has B output": (
filename_without_ext in b_df[file_name_column].values
if b_df is not None
else "No B Output Given"
),
"Has AC output": (
filename_without_ext in ac_df[file_name_column].values
if ac_df is not None
else "No AC Output Given"
),
"# Tables": stats["Table Count"],
"# Reimb rates": stats["Rate Count"],
"Table Pages": ", ".join(map(str, stats["Table Pages"])),
"Rate Pages": ", ".join(map(str, stats["Rate Pages"])),
">=10 Rate Pages": ", ".join(map(str, stats[">=10 Rate Pages"])),
}
)
return table_stats
def perform_qc_qa(ac_final_df, b_final_df, abc_final_df, input_dict):
# Perform QA/QC analysis
wb = Workbook()
ws_general = wb.active
ws_general.title = "General Stats"
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Get list of all processed files
processed_files = []
if ac_final_df is not None:
processed_files.extend(ac_final_df["Contract Name"].tolist())
if b_final_df is not None:
processed_files.extend(b_final_df["Contract Name"].tolist())
processed_files = list(set(processed_files))
# Generate QA/QC stats based on available dataframes
if ac_final_df is not None and b_final_df is not None:
general_stats, ac_stats, b_stats = perform_qc_qa_tests_both(
ac_final_df, b_final_df, abc_final_df, input_dict, processed_files
)
elif ac_final_df is not None:
general_stats, ac_stats = perform_qc_qa_tests_ac(
ac_final_df, input_dict, processed_files
)
else:
general_stats, b_stats = perform_qc_qa_tests_b(
b_final_df, input_dict, processed_files
)
# Add metadata to general stats
general_stats_with_metadata = {
"Report Generation Time": timestamp,
"Batch ID": config.BATCH_ID,
"AC Processing": "Yes" if ("a" in config.FIELDS and "c" in config.FIELDS) or (config.AC_DF) else "No",
"B Processing": "Yes" if ("b" in config.FIELDS) or (config.B_DF) else "No",
**general_stats, }
# Write general stats
general_df = pd.DataFrame.from_dict(
general_stats_with_metadata, orient="index", columns=["Value"]
)
for r in dataframe_to_rows(general_df, index=True, header=True):
ws_general.append(r)
# Write AC stats if available
if ac_final_df is not None:
ws_ac = wb.create_sheet("AC Fields Stats")
ac_stats_df = pd.DataFrame(ac_stats).T
for r in dataframe_to_rows(ac_stats_df, index=True, header=True):
ws_ac.append(r)
# Write B stats if available
if b_final_df is not None:
ws_b = wb.create_sheet("B Fields Stats")
b_stats_df = pd.DataFrame(b_stats).T
for r in dataframe_to_rows(b_stats_df, index=True, header=True):
ws_b.append(r)
# Generate table statistics
table_stats = table_stats_qaqc(input_dict, ac_final_df, b_final_df, config.FILE_NAME_COLUMN)
ws_table_stats = wb.create_sheet("Table and Reimbursement Stats")
table_stats_df = pd.DataFrame(table_stats)
for r in dataframe_to_rows(table_stats_df, index=False, header=True):
ws_table_stats.append(r)
return wb
def save_qcqa_wb(wb, run_timestamp):
# Save QC report locally
report_filename = f'QC_QA_Report_{config.BATCH_ID}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.xlsx'
report_path = os.path.join(config.CONSOLIDATED_OUTPUT_DIRECTORY, report_filename)
wb.save(report_path)
print(f"QC/QA Report saved to {report_path}")
# Upload to S3
if config.WRITE_TO_S3:
BUCKET_NAME = config.S3_OUTPUT_BUCKET
print(f"Uploading final outputs to s3://{BUCKET_NAME}/{run_timestamp}")
# Upload consolidated outputs
consolidated_files = [
f"{config.BATCH_ID}-AC.csv",
f"{config.BATCH_ID}-B.csv",
f"{config.BATCH_ID}-ABC.csv",
report_filename #Add QC Report to upload list
]
for filename in consolidated_files:
local_path = os.path.join(config.CONSOLIDATED_OUTPUT_DIRECTORY, filename)
if os.path.exists(local_path):
s3_key = f"{run_timestamp}/consolidated/{filename}"
try:
print(f"uploading {local_path} to s3://{BUCKET_NAME}/{s3_key}")
config.S3_CLIENT.upload_file(local_path, BUCKET_NAME, s3_key)
except Exception as e:
print(f"Error uploading {local_path}: {str(e)}")
else:
print(f"File not found: {local_path}")
# Upload tracking files
tracking_files = {
'cost_log': config.COST_LOG_NAME,
'file_log': config.FILE_LOG_NAME
}
for file_type, file_name in tracking_files.items():
if os.path.exists(file_name):
try:
print(f"uploading final {file_name}")
s3_key = os.path.join(run_timestamp, file_name).replace("\\", "/")
config.S3_CLIENT.upload_file(file_name, BUCKET_NAME, s3_key)
except Exception as e:
print(f"Error uploading {file_name}: {str(e)}")
print(f"Upload completed to s3://{BUCKET_NAME}/{run_timestamp}")