Merged in feature/new_output_format (pull request #879)
Feature/new output format
* bugfix
* Merge branch 'DEV' into Optimize/DAIP2-1474-restructure-postprocess
* requested postprocessing changes
* prompt changes reverted
* fix pipeline issues
* fix pipeline issues
* fix pipeline issues
* fixed formatting
* fixed formatting
* Merge branch 'DEV' into Optimize/DAIP2-1474-restructure-postprocess
* Merge branch 'DEV' into Optimize/DAIP2-1474-restructure-postprocess
* save dashboard and cc output separately
* save dashboard output in s3
* pipeline error fixed
* json list through postprocessing
* Merged DEV into Optimize/DAIP2-1474-restructure-postprocess
* Merge remote-tracking branch 'origin/Optimize/DAIP2-1474-restructure-postprocess' into feature/new_output_format
* Restructure output file organization and add standard field sanitization
Output Structure Changes:
- Reorganize output files into hierarchical directory structure:
- full_outputs/cc_results/ for consolidated CC results
- full_outputs/dashboard_results/ for consolidated dashboard results
- full_outputs/ for error files
- automation_qa-qc/ for QC/QA validated results and statistics
- parent-child/ for parent-child mapping outputs
- tracking/ for usage and cost tracking data
- individual/ for per-file CC results (dashboard individual files removed)
- Update file naming conventions to match new structure
- Remove QC/QA processing for error files (error files are saved without validation)
Post-Processing Changes:
- Add standard N/A value cleaning: remove placeholder values (N/A, UNKNOWN, etc.)
when they are the only value in a cell (applies before CC/dashboard split)
- Normalize all _IND fields to contain only 'Y' or 'N' values (no blanks)
- Ensure standard cleaning runs before splitting into CC …
* Ran Black for CI
* Refactor file splitting logic and add comprehensive tests
- Refactored splitting logic in io_utils.py:
- Consolidated repeated splitting code into two focused helper functions:
- _write_local_split_files() for local file writing with splitting
- _write_s3_split_files() for S3 file writing with splitting
- Both helpers use shared split_dataframe_by_filename() function
- Added MAX_ROWS_PER_SPLIT configuration (default: 70000) in config.py
- Added comprehensive test coverage for splitting logic:
- Tests for split_dataframe_by_filename() with various scenarios
- Tests for local and S3 write operations with single and multiple splits
- Tests for cc_results_full, dashboard_results_full, and qc_qa_cc_full output types
- Fixed existing test failures (write_s3 error handling, path assertions)
- Improved code maintainability and readability
* Fix failing tests in test_postprocess.py
- Updated standard_postprocess tests to use actual columns from FIELD_FORMAT_MAPPING
(PAYER_NAME, CONTRACT_TITLE) instead of custom test columns that get dropped
- Added FILE_NAME column to all file structure test DataFrames (required for splitting logic)
- Added MAX_ROWS_PER_SPLIT mock configuration for splitting tests
- Fixed patch decorators for S3 tests to properly mock logging
All 41 tests now passing.
* Black for CI
* Blank [] and ['[]'] in output instead of displaying them
- Add placeholder patterns in clean_na_values for [], ['[]'], ["[]"]
- Update format_as_json_list to return blank for empty lists instead of []
- Filter out empty-list placeholder items from list values in format_as_json_list
- Add tests for clean_na_values empty list handling and format_as_json_list
Co-authored-by: Cursor <cursoragent@cursor.com>
* Merged DEV into feature/new_output_format
* Pipeline config, parent-child, dashboard, and runner fixes
- Parent-child: enable by default, write to run directory, S3 upload via io_utils
- Dashboard: optional (CC only by default), run_dashboard=True to enable
- Add io_utils.upload_local_file_to_s3 for centralized file uploads
- Parent-child returns (row_count, local_path); pipeline handles S3 upload
- Add TODO in config for WRITE_PC_TO_S3 removal after approval
- Fix indentation errors in runner.py
Co-authored-by: Cursor <cursoragent@cursor.com>
Approved-by: Katon Minhas
This commit is contained in:
committed by
Katon Minhas
parent
70215bbc69
commit
637d2dea1f
@@ -108,3 +108,4 @@ TEST*.xml
|
||||
# PRD Documents
|
||||
*.prd
|
||||
*prd.md
|
||||
.git.instructions.md
|
||||
|
||||
+12
-1
@@ -343,6 +343,10 @@ MIN_TABLE_ROWS_TO_SPLIT = int(get_arg_value("min_table_rows_to_split", "20"))
|
||||
# Table splitting method (currently only "cells" is supported)
|
||||
TABLE_SPLITTING_METHOD = get_arg_value("table_splitting_method", "cells")
|
||||
|
||||
######################################## OUTPUT FILE SPLITTING SETTINGS ########################################
|
||||
# Maximum rows per split file (files are split to keep filenames together)
|
||||
MAX_ROWS_PER_SPLIT = int(get_arg_value("max_rows_per_split", "70000"))
|
||||
|
||||
######################################## EC2 ########################################
|
||||
|
||||
# DEPRECATED: MODEL_STATS and GLOBAL_STATS are no longer updated.
|
||||
@@ -380,10 +384,12 @@ DOCZY_PDF_FILES_BUCKET_S3_URL = f"https://{DOCZY_PDF_FILES_BUCKET_NAME}.s3.us-ea
|
||||
ENABLE_VISION = get_arg_value("enable_vision", "False") == "True" # False by default
|
||||
|
||||
############## PARENT CHILD CONFIG ##############
|
||||
PERFORM_PARENT_CHILD_MAPPING = get_arg_value("perform_pc", "False") == "True"
|
||||
PERFORM_PARENT_CHILD_MAPPING = get_arg_value("perform_pc", "True") == "True"
|
||||
DOCZY_OUTPUT_FOR_PC = get_arg_value(
|
||||
"doczy_output_for_pc", ""
|
||||
) # Pass either S3 URI path, or local path
|
||||
# TODO: Remove WRITE_PC_TO_S3 after approval. Parent-child S3 upload now uses WRITE_TO_S3
|
||||
# via io_utils.upload_local_file_to_s3 in the pipeline.
|
||||
WRITE_PC_TO_S3 = get_arg_value("write_pc_to_s3", "False") == "True"
|
||||
|
||||
############## DOCUMENT TYPE CLASSIFICATION SETTINGS ##############
|
||||
@@ -399,6 +405,11 @@ DTC_OUTPUT_FILE = (
|
||||
)
|
||||
DTC_JSON_OUTPUT_FOLDER = "dtc_json_results" # Folder for individual JSON results
|
||||
|
||||
############## DASHBOARD POSTPROCESSING ##############
|
||||
# Run dashboard postprocessing (comma-separated list format). Default: CC only.
|
||||
# Set to True via run_dashboard arg when dashboard output is needed.
|
||||
RUN_DASHBOARD_POSTPROCESSING = get_arg_value("run_dashboard", "False") == "True"
|
||||
|
||||
############## QC/QA VALIDATION SETTINGS ##############
|
||||
# Enable QC/QA validation in pipeline
|
||||
ENABLE_QC_QA = get_arg_value("enable_qc_qa", "True") == "True"
|
||||
|
||||
@@ -79,7 +79,11 @@ def write_pc_excel(
|
||||
return output_path
|
||||
|
||||
|
||||
def main(doczy_output_for_pc: str, client: Optional[str] = None) -> int:
|
||||
def main(
|
||||
doczy_output_for_pc: str,
|
||||
client: Optional[str] = None,
|
||||
output_dir: Optional[str] = None,
|
||||
) -> tuple[int, str]:
|
||||
"""
|
||||
Main entry point for parent-child mapping.
|
||||
|
||||
@@ -88,15 +92,20 @@ def main(doczy_output_for_pc: str, client: Optional[str] = None) -> int:
|
||||
client: Optional client identifier. If provided, takes precedence over
|
||||
CLI argument and path-based detection.
|
||||
Supported values: bcbs, caresource, molina, cnc, clover_health
|
||||
output_dir: Directory to write output. When provided by pipeline/batch
|
||||
run, PC results go to the same run directory as other outputs.
|
||||
When None (standalone run), uses outputs/parent_child/{run_timestamp}.
|
||||
|
||||
Returns:
|
||||
Number of rows processed
|
||||
Tuple of (row_count, local_path) where local_path is the written Excel file.
|
||||
"""
|
||||
# Extract client name from input file for output naming
|
||||
client_name = extract_client_name(doczy_output_for_pc)
|
||||
|
||||
# Generate timestamp for this run
|
||||
run_timestamp = f"run_{datetime.now().strftime('%Y%m%d_%H-%M')}_{client_name}"
|
||||
# Pipeline/batch run passes output_dir; standalone uses outputs/parent_child
|
||||
if output_dir is None:
|
||||
run_timestamp = f"run_{datetime.now().strftime('%Y%m%d_%H-%M')}_{client_name}"
|
||||
output_dir = os.path.join("outputs", "parent_child", run_timestamp)
|
||||
|
||||
try:
|
||||
# Read input file
|
||||
@@ -145,21 +154,13 @@ def main(doczy_output_for_pc: str, client: Optional[str] = None) -> int:
|
||||
original_df_with_pc, pc_df = parent_child_mapping(cleaned_df, original_df)
|
||||
|
||||
# Save detailed results
|
||||
output_dir = os.path.join("outputs/parent_child", run_timestamp)
|
||||
pc_df1 = qc_main(pc_df, client)
|
||||
|
||||
output_filename = f"{client_name}_generic_hierarchy_lineage_output.xlsx"
|
||||
if config.WRITE_PC_TO_S3:
|
||||
# Write locally first, then upload to S3
|
||||
write_pc_excel(df_read, pc_df1, output_dir, client_name)
|
||||
# TODO: Add S3 upload logic if needed
|
||||
logging.info(f"PC results saved: {run_timestamp}/{output_filename}")
|
||||
else:
|
||||
write_pc_excel(df_read, pc_df1, output_dir, client_name)
|
||||
logging.info(f"PC results saved locally: {output_dir}/{output_filename}")
|
||||
local_path = write_pc_excel(df_read, pc_df1, output_dir, client_name)
|
||||
|
||||
# TODO: check with dev
|
||||
return pc_df.shape[0] if pc_df is not None else mapped_df.shape[0]
|
||||
row_count = pc_df.shape[0] if pc_df is not None else mapped_df.shape[0]
|
||||
return row_count, local_path
|
||||
except Exception as e:
|
||||
logging.error(f"Parent child mapping failed due to {e}", exc_info=True)
|
||||
raise
|
||||
@@ -173,7 +174,7 @@ if __name__ == "__main__":
|
||||
# Generate timestamp for this run
|
||||
run_timestamp = f"run_{datetime.now().strftime('%Y%m%d_%H-%M')}_{config.BATCH_ID}"
|
||||
if config.DOCZY_OUTPUT_FOR_PC:
|
||||
results = main(config.DOCZY_OUTPUT_FOR_PC)
|
||||
logging.info(f"PC Mapping complete. Processed {results} files.")
|
||||
row_count, _ = main(config.DOCZY_OUTPUT_FOR_PC)
|
||||
logging.info(f"PC Mapping complete. Processed {row_count} files.")
|
||||
else:
|
||||
logging.warning("No input file specified in config.DOCZY_OUTPUT_FOR_PC")
|
||||
|
||||
@@ -161,19 +161,19 @@ def process_file(file_object, constants: Constants, run_timestamp):
|
||||
|
||||
# POSTPROCESS
|
||||
with timing_utils.timed_block("postprocess", context=filename):
|
||||
final_df = postprocess.postprocess(final_results, constants)
|
||||
cc_df, dashboard_df = postprocess.postprocess(final_results, constants)
|
||||
logging.info(f"{datetime_str()} Postprocessing Complete - {filename}")
|
||||
|
||||
################## WRITE INDIVIDUAL ##################
|
||||
with timing_utils.timed_block("write_individual", context=filename):
|
||||
if config.WRITE_TO_S3:
|
||||
io_utils.write_s3(final_df, filename, run_timestamp, "individual")
|
||||
io_utils.write_s3(cc_df, filename, run_timestamp, "individual_cc")
|
||||
else:
|
||||
io_utils.write_local(final_df, filename, "", "individual")
|
||||
io_utils.write_local(cc_df, filename, "", "individual_cc")
|
||||
|
||||
logging.info(f"{datetime_str()} Writing Complete - {filename}")
|
||||
|
||||
return final_df
|
||||
return cc_df, dashboard_df
|
||||
|
||||
|
||||
def run_one_to_one_prompts(
|
||||
|
||||
@@ -161,19 +161,19 @@ def process_file(file_object, constants: Constants, run_timestamp):
|
||||
|
||||
# POSTPROCESS
|
||||
with timing_utils.timed_block("postprocess", context=filename):
|
||||
final_df = postprocess.postprocess(final_results, constants)
|
||||
cc_df, dashboard_df = postprocess.postprocess(final_results, constants)
|
||||
logging.info(f"{datetime_str()} Postprocessing Complete - {filename}")
|
||||
|
||||
################## WRITE INDIVIDUAL ##################
|
||||
with timing_utils.timed_block("write_individual", context=filename):
|
||||
if config.WRITE_TO_S3:
|
||||
io_utils.write_s3(final_df, filename, run_timestamp, "individual")
|
||||
io_utils.write_s3(cc_df, filename, run_timestamp, "individual_cc")
|
||||
else:
|
||||
io_utils.write_local(final_df, filename, "", "individual")
|
||||
io_utils.write_local(cc_df, filename, "", "individual_cc")
|
||||
|
||||
logging.info(f"{datetime_str()} Writing Complete - {filename}")
|
||||
|
||||
return final_df
|
||||
return cc_df, dashboard_df
|
||||
|
||||
|
||||
def run_one_to_one_prompts(
|
||||
|
||||
+131
-40
@@ -13,6 +13,7 @@ Usage:
|
||||
import concurrent.futures
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
@@ -68,14 +69,19 @@ def safe_process_file(item, constants, run_timestamp, file_processing):
|
||||
file_processing: The file_processing module to use
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: Either:
|
||||
- DataFrame with extracted field results (success case)
|
||||
- Single-row DataFrame with error details (failure case)
|
||||
tuple: Either:
|
||||
- (cc_df, dashboard_df) tuple with extracted field results (success case)
|
||||
- (error_df, error_df) tuple with error details (failure case)
|
||||
|
||||
Note:
|
||||
Returns both CC and dashboard versions from file processing.
|
||||
"""
|
||||
file_id = item[0] if isinstance(item, (list, tuple)) and len(item) > 0 else item
|
||||
try:
|
||||
result = file_processing.process_file(item, constants, run_timestamp)
|
||||
return result
|
||||
cc_df, dashboard_df = file_processing.process_file(
|
||||
item, constants, run_timestamp
|
||||
)
|
||||
return cc_df, dashboard_df
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
error_message = str(e)
|
||||
@@ -86,7 +92,7 @@ def safe_process_file(item, constants, run_timestamp, file_processing):
|
||||
logging.error(f"Error Message: {error_message}")
|
||||
logging.error(f"Full traceback:\n{full_traceback}")
|
||||
|
||||
return pd.DataFrame(
|
||||
error_df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"FILE_NAME": file_id,
|
||||
@@ -96,6 +102,7 @@ def safe_process_file(item, constants, run_timestamp, file_processing):
|
||||
}
|
||||
]
|
||||
)
|
||||
return error_df, error_df # Return tuple for consistency
|
||||
|
||||
|
||||
def main(client: str = "saas", testing=False, test_params={}):
|
||||
@@ -177,7 +184,8 @@ def main(client: str = "saas", testing=False, test_params={}):
|
||||
logging.warning(f"Error warming caches (will proceed anyway): {str(e)}")
|
||||
|
||||
# ========== CLIENT-SPECIFIC: File Processing ==========
|
||||
successful_results = []
|
||||
successful_results_cc = []
|
||||
successful_results_dashboard = []
|
||||
error_results = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [
|
||||
@@ -189,49 +197,71 @@ def main(client: str = "saas", testing=False, test_params={}):
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
results = future.result()
|
||||
if "error" in results.columns:
|
||||
error_results.append(results)
|
||||
cc_result, dashboard_result = future.result()
|
||||
if "error" in cc_result.columns:
|
||||
error_results.append(cc_result)
|
||||
else:
|
||||
successful_results.append(results)
|
||||
successful_results_cc.append(cc_result)
|
||||
if (
|
||||
config.RUN_DASHBOARD_POSTPROCESSING
|
||||
and dashboard_result is not None
|
||||
):
|
||||
successful_results_dashboard.append(dashboard_result)
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing future: {str(e)}")
|
||||
error_results.append(pd.DataFrame([{"error": str(e)}]))
|
||||
error_df = pd.DataFrame([{"error": str(e)}])
|
||||
error_results.append(error_df)
|
||||
|
||||
# Combine results
|
||||
if len(successful_results) > 0:
|
||||
FINAL_RESULT_DF = pd.concat(successful_results)
|
||||
else:
|
||||
FINAL_RESULT_DF = pd.DataFrame()
|
||||
# Combine results
|
||||
if len(successful_results_cc) > 0:
|
||||
FINAL_RESULT_DF_CC = pd.concat(successful_results_cc, ignore_index=True)
|
||||
if successful_results_dashboard:
|
||||
FINAL_RESULT_DF_DASHBOARD = pd.concat(
|
||||
successful_results_dashboard, ignore_index=True
|
||||
)
|
||||
else:
|
||||
FINAL_RESULT_DF_DASHBOARD = pd.DataFrame()
|
||||
else:
|
||||
FINAL_RESULT_DF_CC = pd.DataFrame()
|
||||
FINAL_RESULT_DF_DASHBOARD = pd.DataFrame()
|
||||
|
||||
if len(error_results) > 0:
|
||||
ERROR_RESULT_DF = pd.concat(error_results)
|
||||
else:
|
||||
ERROR_RESULT_DF = pd.DataFrame()
|
||||
if len(error_results) > 0:
|
||||
ERROR_RESULT_DF = pd.concat(error_results, ignore_index=True)
|
||||
else:
|
||||
ERROR_RESULT_DF = pd.DataFrame()
|
||||
|
||||
# ========== COMMON: QC/QA Validation ==========
|
||||
if config.ENABLE_QC_QA and not FINAL_RESULT_DF.empty:
|
||||
# Run QC/QA validation by default (preserves automatic behavior for single files and batches)
|
||||
# QC/QA runs on CC version only (dashboard version and error files do not need QC/QA)
|
||||
validated_cc_df = None
|
||||
qc_qa_stats_df = None
|
||||
|
||||
# Run QC/QA on CC version if we have successful results
|
||||
if not FINAL_RESULT_DF_CC.empty:
|
||||
logging.info("=" * 80)
|
||||
logging.info("Running QC/QA Validation Pipeline...")
|
||||
logging.info("Running QC/QA Validation Pipeline on CC results...")
|
||||
logging.info("=" * 80)
|
||||
try:
|
||||
validated_df = run_qc_qa_pipeline(FINAL_RESULT_DF.copy(), stats_df=None)
|
||||
logging.info("QC/QA validation completed successfully")
|
||||
# Ensure unique index before QC/QA (safety check)
|
||||
if FINAL_RESULT_DF_CC.index.has_duplicates:
|
||||
logging.warning(
|
||||
"Detected duplicate indices in FINAL_RESULT_DF_CC, resetting index..."
|
||||
)
|
||||
FINAL_RESULT_DF_CC = FINAL_RESULT_DF_CC.reset_index(drop=True)
|
||||
|
||||
logging.info("Generating QC/QA statistics...")
|
||||
qc_qa_stats_df = generate_statistics(validated_df)
|
||||
logging.info("QC/QA statistics generated successfully")
|
||||
|
||||
save_qc_qa_outputs(
|
||||
validated_df=validated_df,
|
||||
stats_df=qc_qa_stats_df,
|
||||
run_timestamp=run_timestamp,
|
||||
write_to_s3=config.WRITE_TO_S3,
|
||||
validated_cc_df = run_qc_qa_pipeline(
|
||||
FINAL_RESULT_DF_CC.copy(), stats_df=None
|
||||
)
|
||||
logging.info("QC/QA validation on CC results completed successfully")
|
||||
|
||||
# Generate validation statistics from CC version
|
||||
logging.info("Generating QC/QA statistics...")
|
||||
qc_qa_stats_df = generate_statistics(validated_cc_df)
|
||||
logging.info("QC/QA statistics generated successfully")
|
||||
except Exception as e:
|
||||
logging.error(f"QC/QA validation failed: {str(e)}")
|
||||
logging.error(f"QC/QA validation on CC results failed: {str(e)}")
|
||||
logging.error(f"Full traceback:\n{traceback.format_exc()}")
|
||||
logging.warning("Continuing with original unvalidated results...")
|
||||
logging.warning("Continuing with original unvalidated CC results...")
|
||||
logging.info("=" * 80)
|
||||
|
||||
# ========== COMMON: Output & Parent-Child ==========
|
||||
@@ -241,32 +271,93 @@ def main(client: str = "saas", testing=False, test_params={}):
|
||||
)
|
||||
|
||||
if config.WRITE_TO_S3:
|
||||
# Write CC version
|
||||
doczy_output_for_pc = io_utils.write_s3(
|
||||
FINAL_RESULT_DF, "", run_timestamp, "final"
|
||||
FINAL_RESULT_DF_CC, "", run_timestamp, "cc_results_full"
|
||||
)
|
||||
# Write dashboard version (only when dashboard postprocessing is enabled)
|
||||
if (
|
||||
config.RUN_DASHBOARD_POSTPROCESSING
|
||||
and not FINAL_RESULT_DF_DASHBOARD.empty
|
||||
):
|
||||
io_utils.write_s3(
|
||||
FINAL_RESULT_DF_DASHBOARD,
|
||||
"",
|
||||
run_timestamp,
|
||||
"dashboard_results_full",
|
||||
)
|
||||
# Write error file
|
||||
if not ERROR_RESULT_DF.empty:
|
||||
io_utils.write_s3(ERROR_RESULT_DF, "", run_timestamp, "error")
|
||||
|
||||
# Write QC/QA outputs (only for CC version, not dashboard)
|
||||
if validated_cc_df is not None:
|
||||
io_utils.write_s3(validated_cc_df, "", run_timestamp, "qc_qa_cc_full")
|
||||
if qc_qa_stats_df is not None:
|
||||
io_utils.write_s3(qc_qa_stats_df, "", run_timestamp, "qc_qa_stats")
|
||||
|
||||
if not per_file_usage_df.empty and not batch_summary_df.empty:
|
||||
logging.info("Exporting usage and cost tracking data to S3...")
|
||||
io_utils.write_s3(per_file_usage_df, "", run_timestamp, "usage")
|
||||
io_utils.write_s3(batch_summary_df, "", run_timestamp, "usage_summary")
|
||||
else:
|
||||
# Write CC version
|
||||
doczy_output_for_pc = io_utils.write_local(
|
||||
FINAL_RESULT_DF, "", run_timestamp, "final"
|
||||
FINAL_RESULT_DF_CC, "", run_timestamp, "cc_results_full"
|
||||
)
|
||||
# Write dashboard version (only when dashboard postprocessing is enabled)
|
||||
if (
|
||||
config.RUN_DASHBOARD_POSTPROCESSING
|
||||
and not FINAL_RESULT_DF_DASHBOARD.empty
|
||||
):
|
||||
io_utils.write_local(
|
||||
FINAL_RESULT_DF_DASHBOARD,
|
||||
"",
|
||||
run_timestamp,
|
||||
"dashboard_results_full",
|
||||
)
|
||||
# Write error file
|
||||
if not ERROR_RESULT_DF.empty:
|
||||
io_utils.write_local(ERROR_RESULT_DF, "", run_timestamp, "error")
|
||||
|
||||
# Write QC/QA outputs (only for CC version, not dashboard)
|
||||
if validated_cc_df is not None:
|
||||
io_utils.write_local(
|
||||
validated_cc_df, "", run_timestamp, "qc_qa_cc_full"
|
||||
)
|
||||
if qc_qa_stats_df is not None:
|
||||
io_utils.write_local(qc_qa_stats_df, "", run_timestamp, "qc_qa_stats")
|
||||
|
||||
if config.PERFORM_PARENT_CHILD_MAPPING:
|
||||
parent_child_main.main(doczy_output_for_pc, client=client)
|
||||
if FINAL_RESULT_DF_CC.empty:
|
||||
logging.warning(
|
||||
"Skipping Parent-Child mapping: No successful results to process (FINAL_RESULT_DF_CC is empty)"
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
f"Starting Parent-Child mapping with {len(FINAL_RESULT_DF_CC)} records from: {doczy_output_for_pc}"
|
||||
)
|
||||
pc_output_dir = os.path.join(
|
||||
config.CONSOLIDATED_OUTPUT_DIRECTORY,
|
||||
run_timestamp,
|
||||
"parent-child",
|
||||
)
|
||||
_, pc_local_path = parent_child_main.main(
|
||||
doczy_output_for_pc,
|
||||
client=client,
|
||||
output_dir=pc_output_dir,
|
||||
)
|
||||
if config.WRITE_TO_S3 and pc_local_path:
|
||||
io_utils.upload_local_file_to_s3(
|
||||
pc_local_path, run_timestamp, "parent-child"
|
||||
)
|
||||
|
||||
if not per_file_usage_df.empty and not batch_summary_df.empty:
|
||||
logging.info("Exporting usage and cost tracking data locally...")
|
||||
io_utils.write_local(per_file_usage_df, "", run_timestamp, "usage")
|
||||
io_utils.write_local(batch_summary_df, "", run_timestamp, "usage_summary")
|
||||
else:
|
||||
return FINAL_RESULT_DF, ERROR_RESULT_DF
|
||||
return FINAL_RESULT_DF_CC, FINAL_RESULT_DF_DASHBOARD, ERROR_RESULT_DF
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -167,19 +167,19 @@ def process_file(file_object, constants: Constants, run_timestamp):
|
||||
|
||||
# POSTPROCESS
|
||||
with timing_utils.timed_block("postprocess", context=filename):
|
||||
final_df = postprocess.postprocess(final_results, constants)
|
||||
cc_df, dashboard_df = postprocess.postprocess(final_results, constants)
|
||||
logging.info(f"{datetime_str()} Postprocessing Complete - {filename}")
|
||||
|
||||
################## WRITE INDIVIDUAL ##################
|
||||
with timing_utils.timed_block("write_individual", context=filename):
|
||||
if config.WRITE_TO_S3:
|
||||
io_utils.write_s3(final_df, filename, run_timestamp, "individual")
|
||||
io_utils.write_s3(cc_df, filename, run_timestamp, "individual_cc")
|
||||
else:
|
||||
io_utils.write_local(final_df, filename, "", "individual")
|
||||
io_utils.write_local(cc_df, filename, "", "individual_cc")
|
||||
|
||||
logging.info(f"{datetime_str()} Writing Complete - {filename}")
|
||||
|
||||
return final_df
|
||||
return cc_df, dashboard_df
|
||||
|
||||
|
||||
def run_one_to_one_prompts(
|
||||
|
||||
+120
-46
@@ -1,5 +1,6 @@
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import traceback
|
||||
@@ -40,20 +41,22 @@ def safe_process_file(item, constants, run_timestamp):
|
||||
run_timestamp: Timestamp string for output file naming and tracking
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: Either:
|
||||
- DataFrame with extracted field results (success case)
|
||||
- Single-row DataFrame with error details (failure case)
|
||||
tuple: Either:
|
||||
- (cc_df, dashboard_df) tuple with extracted field results (success case)
|
||||
- (error_df, error_df) tuple with error details (failure case)
|
||||
|
||||
Note:
|
||||
Always returns a DataFrame to maintain consistent output format for pd.concat.
|
||||
Returns both CC and dashboard versions from file processing.
|
||||
With FAISS migration, no special resource cleanup is needed.
|
||||
"""
|
||||
file_id = (
|
||||
item[0] if isinstance(item, (list, tuple)) and len(item) > 0 else item
|
||||
) # unpack file_id from item (passed in as a tuple below)
|
||||
try:
|
||||
result = file_processing.process_file(item, constants, run_timestamp)
|
||||
return result
|
||||
cc_df, dashboard_df = file_processing.process_file(
|
||||
item, constants, run_timestamp
|
||||
)
|
||||
return cc_df, dashboard_df
|
||||
except (
|
||||
Exception
|
||||
) as e: # When there's an issue with the processing inside the future
|
||||
@@ -67,7 +70,7 @@ def safe_process_file(item, constants, run_timestamp):
|
||||
|
||||
logging.error(f"Full traceback:\n{full_traceback}")
|
||||
|
||||
return pd.DataFrame(
|
||||
error_df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"FILE_NAME": file_id,
|
||||
@@ -81,6 +84,7 @@ def safe_process_file(item, constants, run_timestamp):
|
||||
}
|
||||
]
|
||||
) # Return a single-row dataframe so we can still concat it later
|
||||
return error_df, error_df # Return tuple for consistency
|
||||
|
||||
|
||||
def main(testing=False, test_params={}):
|
||||
@@ -160,7 +164,8 @@ def main(testing=False, test_params={}):
|
||||
logging.warning(f"Error warming caches (will proceed anyway): {str(e)}")
|
||||
|
||||
# Process files concurrently - each thread gets its own per-file logging context
|
||||
successful_results = []
|
||||
successful_results_cc = []
|
||||
successful_results_dashboard = []
|
||||
error_results = []
|
||||
|
||||
logging.info(f"Starting parallel file processing with {max_workers} workers...")
|
||||
@@ -178,11 +183,16 @@ def main(testing=False, test_params={}):
|
||||
completed_count = 0
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
results = future.result()
|
||||
if "error" in results.columns:
|
||||
error_results.append(results)
|
||||
cc_result, dashboard_result = future.result()
|
||||
if "error" in cc_result.columns:
|
||||
error_results.append(cc_result)
|
||||
else:
|
||||
successful_results.append(results)
|
||||
successful_results_cc.append(cc_result)
|
||||
if (
|
||||
config.RUN_DASHBOARD_POSTPROCESSING
|
||||
and dashboard_result is not None
|
||||
):
|
||||
successful_results_dashboard.append(dashboard_result)
|
||||
|
||||
completed_count += 1
|
||||
if completed_count % 5 == 0: # Log progress every 5 files
|
||||
@@ -194,54 +204,61 @@ def main(testing=False, test_params={}):
|
||||
) as e: # When there's an issue with the future itself (not the processing inside the future)
|
||||
logging.error(f"Error processing future: {str(e)}")
|
||||
# Coerce this to the format expected by pd.concat (so a single-row dataframe)
|
||||
error_results.append(pd.DataFrame([{"error": str(e)}]))
|
||||
error_df = pd.DataFrame([{"error": str(e)}])
|
||||
error_results.append(error_df)
|
||||
|
||||
# only concat if we have results
|
||||
with timing_utils.timed_block("concat_results", log_level="INFO"):
|
||||
if len(successful_results) > 0:
|
||||
FINAL_RESULT_DF = pd.concat(successful_results, ignore_index=True)
|
||||
if len(successful_results_cc) > 0:
|
||||
FINAL_RESULT_DF_CC = pd.concat(successful_results_cc, ignore_index=True)
|
||||
if successful_results_dashboard:
|
||||
FINAL_RESULT_DF_DASHBOARD = pd.concat(
|
||||
successful_results_dashboard, ignore_index=True
|
||||
)
|
||||
else:
|
||||
FINAL_RESULT_DF_DASHBOARD = pd.DataFrame()
|
||||
else:
|
||||
FINAL_RESULT_DF = pd.DataFrame()
|
||||
FINAL_RESULT_DF_CC = pd.DataFrame()
|
||||
FINAL_RESULT_DF_DASHBOARD = pd.DataFrame()
|
||||
|
||||
if len(error_results) > 0:
|
||||
ERROR_RESULT_DF = pd.concat(error_results, ignore_index=True)
|
||||
else:
|
||||
ERROR_RESULT_DF = pd.DataFrame()
|
||||
|
||||
# Run QC/QA validation if enabled (creates separate validated copy)
|
||||
if config.ENABLE_QC_QA and not FINAL_RESULT_DF.empty:
|
||||
# Run QC/QA validation by default (preserves automatic behavior for single files and batches)
|
||||
# QC/QA runs on CC version only (dashboard version and error files do not need QC/QA)
|
||||
validated_cc_df = None
|
||||
qc_qa_stats_df = None
|
||||
|
||||
# Run QC/QA on CC version if we have successful results
|
||||
if not FINAL_RESULT_DF_CC.empty:
|
||||
logging.info("=" * 80)
|
||||
logging.info("Running QC/QA Validation Pipeline...")
|
||||
logging.info("Running QC/QA Validation Pipeline on CC results...")
|
||||
logging.info("=" * 80)
|
||||
with timing_utils.timed_block("qc_qa_validation", log_level="INFO"):
|
||||
try:
|
||||
# Ensure unique index before QC/QA (safety check)
|
||||
if FINAL_RESULT_DF.index.has_duplicates:
|
||||
if FINAL_RESULT_DF_CC.index.has_duplicates:
|
||||
logging.warning(
|
||||
"Detected duplicate indices in FINAL_RESULT_DF, resetting index..."
|
||||
"Detected duplicate indices in FINAL_RESULT_DF_CC, resetting index..."
|
||||
)
|
||||
FINAL_RESULT_DF = FINAL_RESULT_DF.reset_index(drop=True)
|
||||
FINAL_RESULT_DF_CC = FINAL_RESULT_DF_CC.reset_index(drop=True)
|
||||
|
||||
# Create a copy for validation - preserve original FINAL_RESULT_DF
|
||||
validated_df = run_qc_qa_pipeline(FINAL_RESULT_DF.copy(), stats_df=None)
|
||||
logging.info("QC/QA validation completed successfully")
|
||||
|
||||
# Generate validation statistics
|
||||
logging.info("Generating QC/QA statistics...")
|
||||
qc_qa_stats_df = generate_statistics(validated_df)
|
||||
logging.info("QC/QA statistics generated successfully")
|
||||
|
||||
# Save QC/QA outputs (local + S3) - separate from main results
|
||||
save_qc_qa_outputs(
|
||||
validated_df=validated_df,
|
||||
stats_df=qc_qa_stats_df,
|
||||
run_timestamp=run_timestamp,
|
||||
write_to_s3=config.WRITE_TO_S3,
|
||||
# Create a copy for validation - preserve original FINAL_RESULT_DF_CC
|
||||
validated_cc_df = run_qc_qa_pipeline(
|
||||
FINAL_RESULT_DF_CC.copy(), stats_df=None
|
||||
)
|
||||
logging.info("QC/QA validation on CC results completed successfully")
|
||||
|
||||
# Generate validation statistics from CC version
|
||||
logging.info("Generating QC/QA statistics...")
|
||||
qc_qa_stats_df = generate_statistics(validated_cc_df)
|
||||
logging.info("QC/QA statistics generated successfully")
|
||||
except Exception as e:
|
||||
logging.error(f"QC/QA validation failed: {str(e)}")
|
||||
logging.error(f"QC/QA validation on CC results failed: {str(e)}")
|
||||
logging.error(f"Full traceback:\n{traceback.format_exc()}")
|
||||
logging.warning("Continuing with original unvalidated results...")
|
||||
logging.warning("Continuing with original unvalidated CC results...")
|
||||
logging.info("=" * 80)
|
||||
|
||||
if not testing:
|
||||
@@ -252,15 +269,36 @@ def main(testing=False, test_params={}):
|
||||
|
||||
with timing_utils.timed_block("write_outputs", log_level="INFO"):
|
||||
if config.WRITE_TO_S3:
|
||||
# Write CC version
|
||||
doczy_output_for_pc = io_utils.write_s3(
|
||||
FINAL_RESULT_DF, "", run_timestamp, "final"
|
||||
FINAL_RESULT_DF_CC, "", run_timestamp, "cc_results_full"
|
||||
)
|
||||
# Write dashboard version (only when dashboard postprocessing is enabled)
|
||||
if (
|
||||
config.RUN_DASHBOARD_POSTPROCESSING
|
||||
and not FINAL_RESULT_DF_DASHBOARD.empty
|
||||
):
|
||||
io_utils.write_s3(
|
||||
FINAL_RESULT_DF_DASHBOARD,
|
||||
"",
|
||||
run_timestamp,
|
||||
"dashboard_results_full",
|
||||
)
|
||||
# Write error file
|
||||
if not ERROR_RESULT_DF.empty:
|
||||
io_utils.write_s3(ERROR_RESULT_DF, "", run_timestamp, "error")
|
||||
logging.warning(
|
||||
f"{len(ERROR_RESULT_DF)} files failed processing - error results written to S3"
|
||||
)
|
||||
|
||||
# Write QC/QA outputs (only for CC version, not dashboard)
|
||||
if validated_cc_df is not None:
|
||||
io_utils.write_s3(
|
||||
validated_cc_df, "", run_timestamp, "qc_qa_cc_full"
|
||||
)
|
||||
if qc_qa_stats_df is not None:
|
||||
io_utils.write_s3(qc_qa_stats_df, "", run_timestamp, "qc_qa_stats")
|
||||
|
||||
# Export usage and cost tracking data to S3 only if both dataframes have data
|
||||
if not per_file_usage_df.empty and not batch_summary_df.empty:
|
||||
logging.info("Exporting usage and cost tracking data to S3...")
|
||||
@@ -269,23 +307,46 @@ def main(testing=False, test_params={}):
|
||||
batch_summary_df, "", run_timestamp, "usage_summary"
|
||||
)
|
||||
else:
|
||||
# Write CC version
|
||||
doczy_output_for_pc = io_utils.write_local(
|
||||
FINAL_RESULT_DF, "", run_timestamp, "final"
|
||||
FINAL_RESULT_DF_CC, "", run_timestamp, "cc_results_full"
|
||||
)
|
||||
# Write dashboard version (only when dashboard postprocessing is enabled)
|
||||
if (
|
||||
config.RUN_DASHBOARD_POSTPROCESSING
|
||||
and not FINAL_RESULT_DF_DASHBOARD.empty
|
||||
):
|
||||
io_utils.write_local(
|
||||
FINAL_RESULT_DF_DASHBOARD,
|
||||
"",
|
||||
run_timestamp,
|
||||
"dashboard_results_full",
|
||||
)
|
||||
# Write error file
|
||||
if not ERROR_RESULT_DF.empty:
|
||||
io_utils.write_local(ERROR_RESULT_DF, "", run_timestamp, "error")
|
||||
logging.warning(
|
||||
f"{len(ERROR_RESULT_DF)} files failed processing - error results written locally"
|
||||
)
|
||||
|
||||
# Write QC/QA outputs (only for CC version, not dashboard)
|
||||
if validated_cc_df is not None:
|
||||
io_utils.write_local(
|
||||
validated_cc_df, "", run_timestamp, "qc_qa_cc_full"
|
||||
)
|
||||
if qc_qa_stats_df is not None:
|
||||
io_utils.write_local(
|
||||
qc_qa_stats_df, "", run_timestamp, "qc_qa_stats"
|
||||
)
|
||||
|
||||
if config.PERFORM_PARENT_CHILD_MAPPING:
|
||||
if FINAL_RESULT_DF.empty:
|
||||
if FINAL_RESULT_DF_CC.empty:
|
||||
logging.warning(
|
||||
"Skipping Parent-Child mapping: No successful results to process (FINAL_RESULT_DF is empty)"
|
||||
"Skipping Parent-Child mapping: No successful results to process (FINAL_RESULT_DF_CC is empty)"
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
f"Starting Parent-Child mapping with {len(FINAL_RESULT_DF)} records from: {doczy_output_for_pc}"
|
||||
f"Starting Parent-Child mapping with {len(FINAL_RESULT_DF_CC)} records from: {doczy_output_for_pc}"
|
||||
)
|
||||
with timing_utils.timed_block("parent_child_mapping", log_level="INFO"):
|
||||
client = (
|
||||
@@ -295,7 +356,20 @@ def main(testing=False, test_params={}):
|
||||
and config.CLIENT[0] != "None"
|
||||
else None
|
||||
)
|
||||
parent_child_main.main(doczy_output_for_pc, client=client)
|
||||
pc_output_dir = os.path.join(
|
||||
config.CONSOLIDATED_OUTPUT_DIRECTORY,
|
||||
run_timestamp,
|
||||
"parent-child",
|
||||
)
|
||||
_, pc_local_path = parent_child_main.main(
|
||||
doczy_output_for_pc,
|
||||
client=client,
|
||||
output_dir=pc_output_dir,
|
||||
)
|
||||
if config.WRITE_TO_S3 and pc_local_path:
|
||||
io_utils.upload_local_file_to_s3(
|
||||
pc_local_path, run_timestamp, "parent-child"
|
||||
)
|
||||
|
||||
# Export usage and cost tracking data locally only if both dataframes have data
|
||||
if not per_file_usage_df.empty and not batch_summary_df.empty:
|
||||
@@ -310,7 +384,7 @@ def main(testing=False, test_params={}):
|
||||
logging.info("=" * 80)
|
||||
timing_utils.print_hierarchical_timing_summary()
|
||||
else:
|
||||
return FINAL_RESULT_DF, ERROR_RESULT_DF
|
||||
return FINAL_RESULT_DF_CC, FINAL_RESULT_DF_DASHBOARD, ERROR_RESULT_DF
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import json
|
||||
|
||||
import pandas as pd
|
||||
|
||||
import src.config as config
|
||||
from src.constants.constants import Constants
|
||||
from src.constants.investment_columns import FIELD_FORMAT_MAPPING
|
||||
@@ -5,6 +9,35 @@ from src.pipelines.shared.postprocessing import postprocessing_funcs
|
||||
|
||||
|
||||
def postprocess(df, constants: Constants):
|
||||
"""
|
||||
Postprocess the extracted data into CC (Contract Config) and optionally Dashboard format.
|
||||
|
||||
Dashboard postprocessing is skipped unless config.RUN_DASHBOARD_POSTPROCESSING is True.
|
||||
|
||||
Args:
|
||||
df: Raw extracted data DataFrame
|
||||
constants: Constants object
|
||||
|
||||
Returns:
|
||||
tuple: (cc_df, dashboard_df) - CC-formatted and Dashboard-formatted DataFrames.
|
||||
dashboard_df is None when RUN_DASHBOARD_POSTPROCESSING is False.
|
||||
"""
|
||||
# Standard Postprocessing Steps for All Outputs
|
||||
df = standard_postprocess(df, constants)
|
||||
|
||||
# Postprocessing Steps unique to Contract Config Output
|
||||
cc_df = contract_config_postprocess(df, constants)
|
||||
|
||||
# Postprocessing Steps unique to Dashboard Output (optional, off by default)
|
||||
if config.RUN_DASHBOARD_POSTPROCESSING:
|
||||
dashboard_df = dashboard_postprocess(df, constants)
|
||||
else:
|
||||
dashboard_df = None
|
||||
|
||||
return cc_df, dashboard_df
|
||||
|
||||
|
||||
def standard_postprocess(df, constants: Constants):
|
||||
if df.shape[0] == 0:
|
||||
return df
|
||||
|
||||
@@ -23,9 +56,19 @@ def postprocess(df, constants: Constants):
|
||||
sort_columns.append("REIMB_LESSER_OF_ID")
|
||||
df = df.sort_values(by=sort_columns).reset_index(drop=True)
|
||||
|
||||
# Format rate fields
|
||||
# Normalize currency fields and format rate fields
|
||||
if "REIMB_FEE_RATE" in df.columns:
|
||||
df["REIMB_FEE_RATE"] = df["REIMB_FEE_RATE"].apply(
|
||||
postprocessing_funcs.normalize_currency
|
||||
)
|
||||
df["REIMB_FEE_RATE"] = df["REIMB_FEE_RATE"].apply(
|
||||
postprocessing_funcs.format_rate_fields_with_commas
|
||||
)
|
||||
if "REIMB_CONVERSION_FACTOR" in df.columns:
|
||||
df["REIMB_CONVERSION_FACTOR"] = df["REIMB_CONVERSION_FACTOR"].apply(
|
||||
postprocessing_funcs.normalize_currency
|
||||
)
|
||||
df["REIMB_CONVERSION_FACTOR"] = df["REIMB_CONVERSION_FACTOR"].apply(
|
||||
postprocessing_funcs.format_rate_fields_with_commas
|
||||
)
|
||||
if "REIMB_PCT_RATE" in df.columns:
|
||||
@@ -90,4 +133,153 @@ def postprocess(df, constants: Constants):
|
||||
# Standardize output column order - this should ALWAYS be the final postprocessing step
|
||||
df = postprocessing_funcs.reorder_columns(df, FIELD_FORMAT_MAPPING)
|
||||
|
||||
df["FILE_NAME"] = df["FILE_NAME"].apply(postprocessing_funcs.standardize_file_name)
|
||||
|
||||
# This will remove non-standard characters from every cell in the DataFrame
|
||||
df = df.replace(r"[^ -~]+", "", regex=True)
|
||||
|
||||
# Remove N/A values from all columns (standard cleaning for all outputs)
|
||||
df = postprocessing_funcs.clean_na_values(df)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def contract_config_postprocess(df, constants: Constants):
|
||||
cc_df = df.copy()
|
||||
|
||||
if cc_df.shape[0] == 0:
|
||||
return cc_df
|
||||
|
||||
# Contract Config specific postprocessing steps:
|
||||
# Apply json formatting
|
||||
cc_df["PAYER_STATE"] = cc_df["PAYER_STATE"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["PROVIDER_STATE"] = cc_df["PROVIDER_STATE"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["LOB"] = cc_df["LOB"].apply(postprocessing_funcs.format_as_json_list)
|
||||
cc_df["AARETE_DERIVED_LOB"] = cc_df["AARETE_DERIVED_LOB"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["NETWORK"] = cc_df["NETWORK"].apply(postprocessing_funcs.format_as_json_list)
|
||||
cc_df["AARETE_DERIVED_NETWORK"] = cc_df["AARETE_DERIVED_NETWORK"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["PRODUCT"] = cc_df["PRODUCT"].apply(postprocessing_funcs.format_as_json_list)
|
||||
cc_df["AARETE_DERIVED_PRODUCT"] = cc_df["AARETE_DERIVED_PRODUCT"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["PROGRAM"] = cc_df["PROGRAM"].apply(postprocessing_funcs.format_as_json_list)
|
||||
cc_df["AARETE_DERIVED_PROGRAM"] = cc_df["AARETE_DERIVED_PROGRAM"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["REIMB_PROV_TIN"] = cc_df["REIMB_PROV_TIN"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["REIMB_PROV_NPI"] = cc_df["REIMB_PROV_NPI"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["REIMB_PROV_NAME"] = cc_df["REIMB_PROV_NAME"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["PROV_TAXONOMY_CD"] = cc_df["PROV_TAXONOMY_CD"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["PROV_TAXONOMY_CD_DESC"] = cc_df["PROV_TAXONOMY_CD_DESC"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["PROV_SPECIALTY_CD"] = cc_df["PROV_SPECIALTY_CD"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["PROV_SPECIALTY_CD_DESC"] = cc_df["PROV_SPECIALTY_CD_DESC"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["PLACE_OF_SERVICE_CD"] = cc_df["PLACE_OF_SERVICE_CD"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["PLACE_OF_SERVICE_CD_DESC"] = cc_df["PLACE_OF_SERVICE_CD_DESC"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["BILL_TYPE_CD"] = cc_df["BILL_TYPE_CD"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
cc_df["BILL_TYPE_CD_DESC"] = cc_df["BILL_TYPE_CD_DESC"].apply(
|
||||
postprocessing_funcs.format_as_json_list
|
||||
)
|
||||
|
||||
return cc_df
|
||||
|
||||
|
||||
def dashboard_postprocess(df, constants: Constants):
|
||||
dashboard_df = df.copy()
|
||||
|
||||
if dashboard_df.shape[0] == 0:
|
||||
return dashboard_df
|
||||
|
||||
# Dashboard specific postprocessing steps can be added here
|
||||
dashboard_df["PAYER_STATE"] = dashboard_df["PAYER_STATE"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["PROVIDER_STATE"] = dashboard_df["PROVIDER_STATE"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["LOB"] = dashboard_df["LOB"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["AARETE_DERIVED_LOB"] = dashboard_df["AARETE_DERIVED_LOB"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["NETWORK"] = dashboard_df["NETWORK"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["AARETE_DERIVED_NETWORK"] = dashboard_df[
|
||||
"AARETE_DERIVED_NETWORK"
|
||||
].apply(lambda x: ", ".join(x) if isinstance(x, list) else x)
|
||||
dashboard_df["PRODUCT"] = dashboard_df["PRODUCT"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["AARETE_DERIVED_PRODUCT"] = dashboard_df[
|
||||
"AARETE_DERIVED_PRODUCT"
|
||||
].apply(lambda x: ", ".join(x) if isinstance(x, list) else x)
|
||||
dashboard_df["PROGRAM"] = dashboard_df["PROGRAM"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["AARETE_DERIVED_PROGRAM"] = dashboard_df[
|
||||
"AARETE_DERIVED_PROGRAM"
|
||||
].apply(lambda x: ", ".join(x) if isinstance(x, list) else x)
|
||||
dashboard_df["REIMB_PROV_TIN"] = dashboard_df["REIMB_PROV_TIN"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["REIMB_PROV_NPI"] = dashboard_df["REIMB_PROV_NPI"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["REIMB_PROV_NAME"] = dashboard_df["REIMB_PROV_NAME"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["PROV_TAXONOMY_CD"] = dashboard_df["PROV_TAXONOMY_CD"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["PROV_TAXONOMY_CD_DESC"] = dashboard_df["PROV_TAXONOMY_CD_DESC"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["PROV_SPECIALTY_CD"] = dashboard_df["PROV_SPECIALTY_CD"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["PROV_SPECIALTY_CD_DESC"] = dashboard_df[
|
||||
"PROV_SPECIALTY_CD_DESC"
|
||||
].apply(lambda x: ", ".join(x) if isinstance(x, list) else x)
|
||||
dashboard_df["PLACE_OF_SERVICE_CD"] = dashboard_df["PLACE_OF_SERVICE_CD"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["PLACE_OF_SERVICE_CD_DESC"] = dashboard_df[
|
||||
"PLACE_OF_SERVICE_CD_DESC"
|
||||
].apply(lambda x: ", ".join(x) if isinstance(x, list) else x)
|
||||
dashboard_df["BILL_TYPE_CD"] = dashboard_df["BILL_TYPE_CD"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
dashboard_df["BILL_TYPE_CD_DESC"] = dashboard_df["BILL_TYPE_CD_DESC"].apply(
|
||||
lambda x: ", ".join(x) if isinstance(x, list) else x
|
||||
)
|
||||
|
||||
return dashboard_df
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
from datetime import datetime
|
||||
import pandas as pd
|
||||
from src.utils import string_utils
|
||||
import json
|
||||
|
||||
# Determine the base directory for the project
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
@@ -40,16 +41,141 @@ def normalize_indicator_field(value: str) -> str:
|
||||
"""
|
||||
Normalize the indicator field value to 'Y' or 'N'.
|
||||
|
||||
Explicitly handles empty strings, None, NaN, and blank values by setting them to 'N'.
|
||||
|
||||
Args:
|
||||
value (str): The input value from an _IND field.
|
||||
Returns:
|
||||
str: 'Y' if the value is 'Y', otherwise 'N'.
|
||||
"""
|
||||
if isinstance(value, str) and value.strip().upper() == "Y":
|
||||
return "Y"
|
||||
# Handle None, NaN, and empty values
|
||||
if value is None or (isinstance(value, float) and pd.isna(value)):
|
||||
return "N"
|
||||
|
||||
# Handle empty strings and whitespace-only strings
|
||||
if isinstance(value, str):
|
||||
value_stripped = value.strip()
|
||||
if not value_stripped:
|
||||
return "N"
|
||||
if value_stripped.upper() == "Y":
|
||||
return "Y"
|
||||
|
||||
# Everything else becomes 'N'
|
||||
return "N"
|
||||
|
||||
|
||||
def clean_na_values(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Remove cells containing ONLY "N/A", "['N/A']", "[]", "['[]']", "UNKNOWN", or similar placeholder values.
|
||||
|
||||
This function removes placeholder values only when they are the sole value in a cell.
|
||||
If a placeholder value is part of a list with other non-placeholder values, it is kept.
|
||||
Empty list representations (e.g. "[]", "['[]']") are replaced with blank to avoid
|
||||
clutter in the output.
|
||||
|
||||
Args:
|
||||
df (pd.DataFrame): Input DataFrame to clean.
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: DataFrame with placeholder values removed from cells where they are the only value.
|
||||
"""
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
df_cleaned = df.copy()
|
||||
|
||||
# Patterns to match placeholder values (case-insensitive)
|
||||
# Include [] and ['[]'] so empty list representations are blanked instead of shown
|
||||
placeholder_patterns = [
|
||||
r"^N/A$",
|
||||
r"^NA$",
|
||||
r"^NaN$",
|
||||
r"^UNKNOWN$",
|
||||
r"^None$",
|
||||
r"^null$",
|
||||
r"^\['N/A'\]$",
|
||||
r"^\[\"N/A\"\]$",
|
||||
r"^\[N/A\]$",
|
||||
r"^\[\]$", # Empty list "[]"
|
||||
r"^\['\[\]'\]$", # Python repr of list containing "[]"
|
||||
r"^\[\"\[\]\"\]$", # JSON list containing "[]"
|
||||
]
|
||||
|
||||
def is_placeholder_only(value):
|
||||
"""Check if value is only a placeholder."""
|
||||
# Handle None, NaN, and pd.NA
|
||||
if value is None:
|
||||
return True
|
||||
try:
|
||||
if pd.isna(value):
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
# pd.isna might raise TypeError for some types, continue checking
|
||||
pass
|
||||
|
||||
if isinstance(value, str):
|
||||
value_stripped = value.strip()
|
||||
if not value_stripped:
|
||||
return True
|
||||
|
||||
# Try to parse as JSON list first
|
||||
if value_stripped.startswith("[") and value_stripped.endswith("]"):
|
||||
try:
|
||||
parsed_list = json.loads(value_stripped)
|
||||
if isinstance(parsed_list, list):
|
||||
# If list contains only placeholder values, consider it placeholder
|
||||
if len(parsed_list) == 0:
|
||||
return True
|
||||
all_placeholders = True
|
||||
for item in parsed_list:
|
||||
item_str = str(item).strip()
|
||||
is_placeholder = False
|
||||
for pattern in placeholder_patterns:
|
||||
if re.match(pattern, item_str, re.IGNORECASE):
|
||||
is_placeholder = True
|
||||
break
|
||||
if not is_placeholder and item_str:
|
||||
all_placeholders = False
|
||||
break
|
||||
return all_placeholders
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# If JSON parsing fails, continue with string matching
|
||||
pass
|
||||
|
||||
# Check against placeholder patterns for string values
|
||||
for pattern in placeholder_patterns:
|
||||
if re.match(pattern, value_stripped, re.IGNORECASE):
|
||||
return True
|
||||
|
||||
# Handle list types (Python lists)
|
||||
if isinstance(value, list):
|
||||
if len(value) == 0:
|
||||
return True
|
||||
# If list contains only placeholder values, consider it placeholder
|
||||
all_placeholders = True
|
||||
for item in value:
|
||||
item_str = str(item).strip()
|
||||
is_placeholder = False
|
||||
for pattern in placeholder_patterns:
|
||||
if re.match(pattern, item_str, re.IGNORECASE):
|
||||
is_placeholder = True
|
||||
break
|
||||
if not is_placeholder and item_str:
|
||||
all_placeholders = False
|
||||
break
|
||||
return all_placeholders
|
||||
|
||||
return False
|
||||
|
||||
# Apply cleaning to all columns
|
||||
for col in df_cleaned.columns:
|
||||
df_cleaned[col] = df_cleaned[col].apply(
|
||||
lambda x: "" if is_placeholder_only(x) else x
|
||||
)
|
||||
|
||||
return df_cleaned
|
||||
|
||||
|
||||
def format_rate_fields_with_commas(value: str) -> str:
|
||||
"""
|
||||
Format the rate with commas and two decimal places.
|
||||
@@ -69,6 +195,32 @@ def format_rate_fields_with_commas(value: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_currency(value: str) -> str:
|
||||
"""
|
||||
Normalize currency-like strings by removing $ and commas.
|
||||
|
||||
Args:
|
||||
value (str): A string representing a currency value.
|
||||
Returns:
|
||||
str: Normalized numeric string or empty string if invalid.
|
||||
"""
|
||||
if string_utils.is_empty(value):
|
||||
return ""
|
||||
|
||||
if isinstance(value, (int, float)) and not pd.isna(value):
|
||||
return str(value)
|
||||
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
|
||||
text = value.strip()
|
||||
if text.upper() in {"N/A", "NA", "NAN"}:
|
||||
return ""
|
||||
|
||||
text = text.replace("$", "").replace(",", "").strip()
|
||||
return text
|
||||
|
||||
|
||||
def remove_hyphens(value):
|
||||
"""
|
||||
Remove hyphens from the input value.
|
||||
@@ -238,7 +390,7 @@ def normalize_cpt_fields(value):
|
||||
else:
|
||||
result = [str(value).strip()] # Default case for other types
|
||||
|
||||
return str(result) # Return the list directly
|
||||
return json.dumps(result) # Return JSON list string
|
||||
|
||||
|
||||
def generate_reimb_ids(df: pd.DataFrame) -> pd.DataFrame:
|
||||
@@ -509,6 +661,30 @@ def add_aarete_derived_amendment_num(df: pd.DataFrame) -> pd.DataFrame:
|
||||
pd.DataFrame: The updated DataFrame with the 'AARETE_DERIVED_AMENDMENT_NUM' column.
|
||||
"""
|
||||
|
||||
def derive_amendment_num(value):
|
||||
if value is None or (isinstance(value, float) and pd.isna(value)):
|
||||
return "0"
|
||||
|
||||
if not isinstance(value, str):
|
||||
value = str(value)
|
||||
|
||||
text = value.strip()
|
||||
if string_utils.is_empty(text):
|
||||
return "0"
|
||||
|
||||
lowered = text.lower()
|
||||
if lowered in {"n/a", "na", "nan", "unknown", "unspecified", "none", "null"}:
|
||||
return "0"
|
||||
|
||||
if any(token in lowered for token in ["base", "original", "master"]):
|
||||
return "N/A"
|
||||
|
||||
numbers = re.findall(r"\b\d+\b", text)
|
||||
if numbers:
|
||||
return str(int(numbers[-1]))
|
||||
|
||||
return "0"
|
||||
|
||||
if "CONTRACT_AMENDMENT_NUM" in df.columns:
|
||||
df["AARETE_DERIVED_AMENDMENT_NUM"] = (
|
||||
df["CONTRACT_AMENDMENT_NUM"]
|
||||
@@ -517,6 +693,10 @@ def add_aarete_derived_amendment_num(df: pd.DataFrame) -> pd.DataFrame:
|
||||
.fillna(df["CONTRACT_AMENDMENT_NUM"])
|
||||
)
|
||||
|
||||
df["AARETE_DERIVED_AMENDMENT_NUM"] = df["CONTRACT_AMENDMENT_NUM"].apply(
|
||||
derive_amendment_num
|
||||
)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
@@ -861,6 +1041,24 @@ def add_aarete_derived_signatory_complete_ind(df: pd.DataFrame) -> pd.DataFrame:
|
||||
return df
|
||||
|
||||
|
||||
def standardize_file_name(file_name: str) -> str:
|
||||
"""
|
||||
Standardizes the given file name by applying a series of regex replacements
|
||||
to normalize terms related to time periods.
|
||||
|
||||
Args:
|
||||
file_name (str): The original file name string.
|
||||
|
||||
Returns:
|
||||
str: The standardized file name string.
|
||||
"""
|
||||
|
||||
# remove .txt from the end if present
|
||||
file_name = re.sub(r"\.txt$", "", file_name, flags=re.IGNORECASE)
|
||||
|
||||
return file_name
|
||||
|
||||
|
||||
def fill_claim_type_from_title(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Fill empty AARETE_DERIVED_CLAIM_TYPE_CD values using two strategies:
|
||||
@@ -978,3 +1176,52 @@ def fill_claim_type_from_title(df: pd.DataFrame) -> pd.DataFrame:
|
||||
df["AARETE_DERIVED_CLAIM_TYPE_CD"] = df.apply(infer_from_title, axis=1)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def _is_empty_list_placeholder(item) -> bool:
|
||||
"""Return True if item is "[]", "['[]']", or similar empty-list representation."""
|
||||
if item is None or (isinstance(item, float) and pd.isna(item)):
|
||||
return True
|
||||
s = str(item).strip()
|
||||
return s in ("[]", "['[]']", '["[]"]') or s == ""
|
||||
|
||||
|
||||
def format_as_json_list(val):
|
||||
"""
|
||||
Format a value as a JSON list string. Returns blank for empty lists
|
||||
instead of "[]" to avoid clutter in output.
|
||||
"""
|
||||
# 1. Handle actual lists or Nulls
|
||||
if isinstance(val, list):
|
||||
filtered = [
|
||||
str(i).strip()
|
||||
for i in val
|
||||
if i is not None and not _is_empty_list_placeholder(i)
|
||||
]
|
||||
return json.dumps(filtered) if filtered else ""
|
||||
if pd.isna(val) or val == "" or val == "[]":
|
||||
return ""
|
||||
|
||||
# 2. Cleanup & Parsing
|
||||
text = str(val).strip()
|
||||
|
||||
# Try to parse as JSON if it looks like a list
|
||||
if text.startswith("[") and text.endswith("]"):
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, list):
|
||||
filtered = [
|
||||
str(i).strip()
|
||||
for i in parsed
|
||||
if i is not None and not _is_empty_list_placeholder(i)
|
||||
]
|
||||
return json.dumps(filtered) if filtered else ""
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
text = text.strip("[]") # Strip brackets if it's a "fake" list like ['A']
|
||||
|
||||
# 3. Handle comma-separated strings & Clean special characters
|
||||
# This regex keeps letters, numbers, commas, and spaces
|
||||
clean_text = re.sub(r"[^a-zA-Z0-9, ]+", "", text)
|
||||
items = [i.strip() for i in clean_text.split(",") if i.strip()]
|
||||
|
||||
return json.dumps(items) if items else ""
|
||||
|
||||
+262
-3
@@ -14,6 +14,8 @@ from src.utils.io_utils import (
|
||||
read_s3_csv,
|
||||
read_xlsb,
|
||||
remove_txt_extension,
|
||||
split_dataframe_by_filename,
|
||||
write_local,
|
||||
write_s3,
|
||||
)
|
||||
|
||||
@@ -373,7 +375,7 @@ class TestIOUtils:
|
||||
call_kwargs = mock_s3_client.put_object.call_args[1]
|
||||
assert (
|
||||
call_kwargs["Key"]
|
||||
== "test_batch_123/20240101120000/test_batch_123-ERRORS.csv"
|
||||
== "test_batch_123/20240101120000/full_outputs/test_batch_123-ERRORS.csv"
|
||||
)
|
||||
|
||||
def test_write_s3_usage(self, mocker):
|
||||
@@ -516,6 +518,263 @@ class TestIOUtils:
|
||||
mock_to_csv.assert_called_once()
|
||||
call_args = mock_to_csv.call_args[0][0]
|
||||
|
||||
# Check output path includes outputs/qc_qa folder and correct filename
|
||||
assert "outputs/qc_qa" in call_args
|
||||
# Check output path includes automation_qa-qc folder and correct filename
|
||||
assert "automation_qa-qc" in call_args
|
||||
assert "QC-QA-STATS.csv" in call_args
|
||||
|
||||
# Tests for splitting logic
|
||||
def test_split_dataframe_by_filename_empty(self):
|
||||
"""Test split_dataframe_by_filename with empty DataFrame."""
|
||||
df = pd.DataFrame()
|
||||
result = split_dataframe_by_filename(df, 70000)
|
||||
assert len(result) == 1
|
||||
assert result[0].empty
|
||||
|
||||
def test_split_dataframe_by_filename_no_file_name_column(self):
|
||||
"""Test split_dataframe_by_filename raises error without FILE_NAME column."""
|
||||
df = pd.DataFrame({"col1": [1, 2], "col2": [3, 4]})
|
||||
with pytest.raises(ValueError, match="FILE_NAME"):
|
||||
split_dataframe_by_filename(df, 70000)
|
||||
|
||||
def test_split_dataframe_by_filename_single_file_small(self):
|
||||
"""Test split_dataframe_by_filename with single file under limit."""
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"FILE_NAME": ["file1.txt"] * 10,
|
||||
"col1": range(10),
|
||||
"col2": range(10, 20),
|
||||
}
|
||||
)
|
||||
result = split_dataframe_by_filename(df, 70000)
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 10
|
||||
assert all(result[0]["FILE_NAME"] == "file1.txt")
|
||||
|
||||
def test_split_dataframe_by_filename_single_file_large(self):
|
||||
"""Test split_dataframe_by_filename with single file over limit."""
|
||||
# Create a DataFrame with one file that exceeds the limit
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"FILE_NAME": ["large_file.txt"] * 80000,
|
||||
"col1": range(80000),
|
||||
}
|
||||
)
|
||||
result = split_dataframe_by_filename(df, 70000)
|
||||
# Should still be one split since we can't break up a filename
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 80000
|
||||
|
||||
def test_split_dataframe_by_filename_multiple_files_sorted(self):
|
||||
"""Test split_dataframe_by_filename sorts by row count ascending."""
|
||||
# Create files with different row counts
|
||||
# small.txt: 10 rows total (5 + 5)
|
||||
# medium.txt: 40 rows total (20 + 20)
|
||||
# large.txt: 50 rows
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"FILE_NAME": (
|
||||
["small.txt"] * 5
|
||||
+ ["medium.txt"] * 20
|
||||
+ ["large.txt"] * 50
|
||||
+ ["small.txt"] * 5
|
||||
+ ["medium.txt"] * 20
|
||||
),
|
||||
"col1": range(100),
|
||||
}
|
||||
)
|
||||
result = split_dataframe_by_filename(df, 30)
|
||||
# With max_rows=30:
|
||||
# - small (10) fits in first split
|
||||
# - medium (40) alone exceeds 30, so new split
|
||||
# - large (50) alone exceeds 30, so new split
|
||||
# Result: 3 splits
|
||||
assert len(result) == 3
|
||||
# First split should have small (10 rows)
|
||||
assert len(result[0]) == 10
|
||||
assert set(result[0]["FILE_NAME"].unique()) == {"small.txt"}
|
||||
# Second split should have medium (40 rows)
|
||||
assert len(result[1]) == 40
|
||||
assert set(result[1]["FILE_NAME"].unique()) == {"medium.txt"}
|
||||
# Third split should have large (50 rows)
|
||||
assert len(result[2]) == 50
|
||||
assert set(result[2]["FILE_NAME"].unique()) == {"large.txt"}
|
||||
|
||||
def test_split_dataframe_by_filename_multiple_splits(self):
|
||||
"""Test split_dataframe_by_filename creates multiple splits correctly."""
|
||||
# Create multiple files that will need splitting
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"FILE_NAME": (
|
||||
["file1.txt"] * 40000
|
||||
+ ["file2.txt"] * 40000
|
||||
+ ["file3.txt"] * 40000
|
||||
),
|
||||
"col1": range(120000),
|
||||
}
|
||||
)
|
||||
result = split_dataframe_by_filename(df, 70000)
|
||||
# Each file is 40k, so: file1 (40k) fits, file2 (40k) would make 80k > 70k, so separate
|
||||
# So we should get 3 splits
|
||||
assert len(result) == 3
|
||||
assert all(len(split) == 40000 for split in result)
|
||||
|
||||
def test_write_local_cc_results_full_single_split(self, mocker, tmp_path):
|
||||
"""Test write_local with cc_results_full that doesn't need splitting."""
|
||||
mocker.patch("src.config.BATCH_ID", "test_batch")
|
||||
mocker.patch("src.config.CONSOLIDATED_OUTPUT_DIRECTORY", str(tmp_path))
|
||||
mocker.patch("src.config.PERFORM_PARENT_CHILD_MAPPING", False)
|
||||
mocker.patch("src.config.MAX_ROWS_PER_SPLIT", 70000)
|
||||
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"FILE_NAME": ["file1.txt"] * 10,
|
||||
"col1": range(10),
|
||||
}
|
||||
)
|
||||
result = write_local(df, "", "run_20240101_10-00_test", "cc_results_full")
|
||||
|
||||
# Should create FULL file
|
||||
expected_file = (
|
||||
tmp_path
|
||||
/ "run_20240101_10-00_test"
|
||||
/ "full_outputs"
|
||||
/ "cc_results"
|
||||
/ "test_batch-RESULTS-FULL.csv"
|
||||
)
|
||||
assert expected_file.exists()
|
||||
assert result is None # No parent-child mapping
|
||||
|
||||
def test_write_local_cc_results_full_multiple_splits(self, mocker, tmp_path):
|
||||
"""Test write_local with cc_results_full that needs splitting."""
|
||||
mocker.patch("src.config.BATCH_ID", "test_batch")
|
||||
mocker.patch("src.config.CONSOLIDATED_OUTPUT_DIRECTORY", str(tmp_path))
|
||||
mocker.patch("src.config.PERFORM_PARENT_CHILD_MAPPING", True)
|
||||
mocker.patch("src.config.MAX_ROWS_PER_SPLIT", 30)
|
||||
|
||||
# Create data that will need splitting
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"FILE_NAME": (
|
||||
["file1.txt"] * 20 + ["file2.txt"] * 20 + ["file3.txt"] * 20
|
||||
),
|
||||
"col1": range(60),
|
||||
}
|
||||
)
|
||||
result = write_local(df, "", "run_20240101_10-00_test", "cc_results_full")
|
||||
|
||||
# Should create SPLIT files
|
||||
output_dir = (
|
||||
tmp_path / "run_20240101_10-00_test" / "full_outputs" / "cc_results"
|
||||
)
|
||||
split1_file = output_dir / "test_batch-RESULTS-SPLIT1.csv"
|
||||
split2_file = output_dir / "test_batch-RESULTS-SPLIT2.csv"
|
||||
|
||||
assert split1_file.exists()
|
||||
assert split2_file.exists()
|
||||
# Should return first split for parent-child mapping
|
||||
assert result == str(split1_file)
|
||||
|
||||
def test_write_s3_cc_results_full_single_split(self, mocker):
|
||||
"""Test write_s3 with cc_results_full that doesn't need splitting."""
|
||||
mock_s3_client = mocker.MagicMock()
|
||||
mocker.patch("src.config.S3_CLIENT", mock_s3_client)
|
||||
mocker.patch("src.config.BATCH_ID", "test_batch")
|
||||
mocker.patch("src.config.S3_OUTPUT_BUCKET", "test-bucket")
|
||||
mocker.patch("src.config.PERFORM_PARENT_CHILD_MAPPING", False)
|
||||
mocker.patch("src.config.MAX_ROWS_PER_SPLIT", 70000)
|
||||
mocker.patch("src.utils.io_utils.logging")
|
||||
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"FILE_NAME": ["file1.txt"] * 10,
|
||||
"col1": range(10),
|
||||
}
|
||||
)
|
||||
write_s3(df, "", "run_20240101_10-00_test", "cc_results_full")
|
||||
|
||||
# Should upload FULL file
|
||||
mock_s3_client.put_object.assert_called_once()
|
||||
call_kwargs = mock_s3_client.put_object.call_args[1]
|
||||
assert "full_outputs/cc_results" in call_kwargs["Key"]
|
||||
assert "RESULTS-FULL.csv" in call_kwargs["Key"]
|
||||
|
||||
def test_write_s3_cc_results_full_multiple_splits(self, mocker):
|
||||
"""Test write_s3 with cc_results_full that needs splitting."""
|
||||
mock_s3_client = mocker.MagicMock()
|
||||
mocker.patch("src.config.S3_CLIENT", mock_s3_client)
|
||||
mocker.patch("src.config.BATCH_ID", "test_batch")
|
||||
mocker.patch("src.config.S3_OUTPUT_BUCKET", "test-bucket")
|
||||
mocker.patch("src.config.PERFORM_PARENT_CHILD_MAPPING", True)
|
||||
mocker.patch("src.config.MAX_ROWS_PER_SPLIT", 30)
|
||||
mocker.patch("src.utils.io_utils.logging")
|
||||
|
||||
# Create data that will need splitting
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"FILE_NAME": (
|
||||
["file1.txt"] * 20 + ["file2.txt"] * 20 + ["file3.txt"] * 20
|
||||
),
|
||||
"col1": range(60),
|
||||
}
|
||||
)
|
||||
result = write_s3(df, "", "run_20240101_10-00_test", "cc_results_full")
|
||||
|
||||
# Should upload multiple SPLIT files
|
||||
assert mock_s3_client.put_object.call_count >= 2
|
||||
# Check that SPLIT files were uploaded
|
||||
call_keys = [
|
||||
call[1]["Key"] for call in mock_s3_client.put_object.call_args_list
|
||||
]
|
||||
assert any("SPLIT1" in key for key in call_keys)
|
||||
assert any("SPLIT2" in key for key in call_keys)
|
||||
# Should return first split path for parent-child mapping
|
||||
assert result is not None
|
||||
assert "SPLIT1" in result
|
||||
|
||||
def test_write_s3_dashboard_results_full_splits(self, mocker):
|
||||
"""Test write_s3 with dashboard_results_full that needs splitting."""
|
||||
mock_s3_client = mocker.MagicMock()
|
||||
mocker.patch("src.config.S3_CLIENT", mock_s3_client)
|
||||
mocker.patch("src.config.BATCH_ID", "test_batch")
|
||||
mocker.patch("src.config.S3_OUTPUT_BUCKET", "test-bucket")
|
||||
mocker.patch("src.config.MAX_ROWS_PER_SPLIT", 30)
|
||||
mocker.patch("src.utils.io_utils.logging")
|
||||
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"FILE_NAME": (["file1.txt"] * 20 + ["file2.txt"] * 20),
|
||||
"col1": range(40),
|
||||
}
|
||||
)
|
||||
write_s3(df, "", "run_20240101_10-00_test", "dashboard_results_full")
|
||||
|
||||
# Should upload multiple SPLIT files
|
||||
assert mock_s3_client.put_object.call_count >= 2
|
||||
call_keys = [
|
||||
call[1]["Key"] for call in mock_s3_client.put_object.call_args_list
|
||||
]
|
||||
assert any("dashboard-SPLIT" in key for key in call_keys)
|
||||
|
||||
def test_write_s3_qc_qa_cc_full_splits(self, mocker):
|
||||
"""Test write_s3 with qc_qa_cc_full that needs splitting."""
|
||||
mock_s3_client = mocker.MagicMock()
|
||||
mocker.patch("src.config.S3_CLIENT", mock_s3_client)
|
||||
mocker.patch("src.config.BATCH_ID", "test_batch")
|
||||
mocker.patch("src.config.S3_OUTPUT_BUCKET", "test-bucket")
|
||||
mocker.patch("src.config.MAX_ROWS_PER_SPLIT", 30)
|
||||
mocker.patch("src.utils.io_utils.logging")
|
||||
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"FILE_NAME": (["file1.txt"] * 20 + ["file2.txt"] * 20),
|
||||
"col1": range(40),
|
||||
}
|
||||
)
|
||||
write_s3(df, "", "run_20240101_10-00_test", "qc_qa_cc_full")
|
||||
|
||||
# Should upload multiple SPLIT files
|
||||
assert mock_s3_client.put_object.call_count >= 2
|
||||
call_keys = [
|
||||
call[1]["Key"] for call in mock_s3_client.put_object.call_args_list
|
||||
]
|
||||
assert any("QC-QA-SPLIT" in key for key in call_keys)
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from src.pipelines.shared.postprocessing.postprocessing_funcs import (
|
||||
clean_na_values,
|
||||
deduplicate_provider_columns,
|
||||
fill_claim_type_from_title,
|
||||
flatten_singleton_string_list,
|
||||
format_as_json_list,
|
||||
format_rate_fields_with_commas,
|
||||
normalize_auto_renewal_term,
|
||||
normalize_cpt_fields,
|
||||
@@ -17,16 +22,33 @@ from src.pipelines.shared.postprocessing.postprocessing_funcs import (
|
||||
rename_columns,
|
||||
validate_and_reformat_date,
|
||||
)
|
||||
from src.pipelines.shared.postprocessing.postprocess import standard_postprocess
|
||||
from src.constants.constants import Constants
|
||||
|
||||
|
||||
class TestPostprocessFunctions(unittest.TestCase):
|
||||
|
||||
def test_normalize_indicator_field(self):
|
||||
"""Test normalize_indicator_field with various inputs including blanks/None/NaN."""
|
||||
self.assertEqual(normalize_indicator_field("Y"), "Y")
|
||||
self.assertEqual(normalize_indicator_field("y"), "Y")
|
||||
self.assertEqual(normalize_indicator_field("N"), "N")
|
||||
self.assertEqual(normalize_indicator_field(""), "N")
|
||||
self.assertEqual(normalize_indicator_field(None), "N")
|
||||
# Test with whitespace-only strings
|
||||
self.assertEqual(normalize_indicator_field(" "), "N")
|
||||
self.assertEqual(normalize_indicator_field("\t\n"), "N")
|
||||
# Test with NaN (float)
|
||||
import numpy as np
|
||||
|
||||
self.assertEqual(normalize_indicator_field(np.nan), "N")
|
||||
self.assertEqual(normalize_indicator_field(float("nan")), "N")
|
||||
# Test with pd.NA
|
||||
self.assertEqual(normalize_indicator_field(pd.NA), "N")
|
||||
# Test that non-Y values become N
|
||||
self.assertEqual(normalize_indicator_field("X"), "N")
|
||||
self.assertEqual(normalize_indicator_field("Yes"), "N")
|
||||
self.assertEqual(normalize_indicator_field("1"), "N")
|
||||
|
||||
def test_format_rate_fields_with_commas(self):
|
||||
# Test numeric values
|
||||
@@ -48,6 +70,17 @@ class TestPostprocessFunctions(unittest.TestCase):
|
||||
self.assertEqual(flatten_singleton_string_list("invalid"), "invalid")
|
||||
self.assertEqual(flatten_singleton_string_list(None), "")
|
||||
|
||||
def test_format_as_json_list_returns_blank_for_empty(self):
|
||||
"""Test format_as_json_list returns blank instead of [] for empty lists."""
|
||||
self.assertEqual(format_as_json_list([]), "")
|
||||
self.assertEqual(format_as_json_list("[]"), "")
|
||||
self.assertEqual(format_as_json_list(None), "")
|
||||
self.assertEqual(format_as_json_list(""), "")
|
||||
self.assertEqual(format_as_json_list([""]), "")
|
||||
self.assertEqual(format_as_json_list(["[]"]), "")
|
||||
self.assertEqual(format_as_json_list(["Valid"]), '["Valid"]')
|
||||
self.assertEqual(format_as_json_list(["A", "B"]), '["A", "B"]')
|
||||
|
||||
def test_rename_columns(self):
|
||||
"""Tests the rename_columns function to ensure it correctly renames specified columns.
|
||||
|
||||
@@ -108,15 +141,15 @@ class TestPostprocessFunctions(unittest.TestCase):
|
||||
self.assertEqual(normalize_auto_renewal_term(None), "")
|
||||
|
||||
def test_normalize_cpt_fields(self):
|
||||
self.assertEqual(normalize_cpt_fields("[123, 456]"), "['123', '456']")
|
||||
self.assertEqual(normalize_cpt_fields("123-456"), "['123-456']")
|
||||
self.assertEqual(normalize_cpt_fields("[123, 456]"), '["123", "456"]')
|
||||
self.assertEqual(normalize_cpt_fields("123-456"), '["123-456"]')
|
||||
self.assertEqual(
|
||||
normalize_cpt_fields("['T0000-T9999, S0000-S9999']"),
|
||||
"['T0000-T9999', 'S0000-S9999']",
|
||||
'["T0000-T9999", "S0000-S9999"]',
|
||||
)
|
||||
self.assertEqual(normalize_cpt_fields("123"), "['123']")
|
||||
self.assertEqual(normalize_cpt_fields("123"), '["123"]')
|
||||
self.assertEqual(normalize_cpt_fields(None), "")
|
||||
self.assertEqual(normalize_cpt_fields(123), "['123']")
|
||||
self.assertEqual(normalize_cpt_fields(123), '["123"]')
|
||||
|
||||
def test_process_patient_age_range(self):
|
||||
"""Tests the process_patient_age_range function with various age range formats.
|
||||
@@ -662,6 +695,496 @@ class TestPostprocessFunctions(unittest.TestCase):
|
||||
# Empty list should be treated as empty and filled with mode or inferred
|
||||
assert result_df.loc[0, "AARETE_DERIVED_CLAIM_TYPE_CD"] == "M"
|
||||
|
||||
def test_clean_na_values_string_placeholders(self):
|
||||
"""Test clean_na_values removes placeholder strings when they're the only value."""
|
||||
# Test with string placeholders
|
||||
input_df = pd.DataFrame(
|
||||
{
|
||||
"FIELD1": ["N/A", "Valid Value", "UNKNOWN", "NA"],
|
||||
"FIELD2": ["Valid", "N/A", "Another Valid", "null"],
|
||||
}
|
||||
)
|
||||
|
||||
result_df = clean_na_values(input_df)
|
||||
|
||||
# N/A, UNKNOWN, NA, null should be removed (become empty strings)
|
||||
self.assertEqual(result_df.loc[0, "FIELD1"], "")
|
||||
self.assertEqual(result_df.loc[1, "FIELD1"], "Valid Value")
|
||||
self.assertEqual(result_df.loc[2, "FIELD1"], "")
|
||||
self.assertEqual(result_df.loc[3, "FIELD1"], "")
|
||||
|
||||
self.assertEqual(result_df.loc[0, "FIELD2"], "Valid")
|
||||
self.assertEqual(result_df.loc[1, "FIELD2"], "")
|
||||
self.assertEqual(result_df.loc[2, "FIELD2"], "Another Valid")
|
||||
self.assertEqual(result_df.loc[3, "FIELD2"], "")
|
||||
|
||||
def test_clean_na_values_json_list_placeholders(self):
|
||||
"""Test clean_na_values handles JSON list placeholders."""
|
||||
# Test with JSON string lists
|
||||
input_df = pd.DataFrame(
|
||||
{
|
||||
"FIELD1": ['["N/A"]', '["Medicare", "N/A"]', '["N/A", "N/A"]'],
|
||||
"FIELD2": ['["Valid"]', '["N/A"]', '["Medicare"]'],
|
||||
}
|
||||
)
|
||||
|
||||
result_df = clean_na_values(input_df)
|
||||
|
||||
# '["N/A"]' should be removed (only placeholder)
|
||||
self.assertEqual(result_df.loc[0, "FIELD1"], "")
|
||||
# '["Medicare", "N/A"]' should be kept (has non-placeholder)
|
||||
self.assertEqual(result_df.loc[1, "FIELD1"], '["Medicare", "N/A"]')
|
||||
# '["N/A", "N/A"]' should be removed (all placeholders)
|
||||
self.assertEqual(result_df.loc[2, "FIELD1"], "")
|
||||
|
||||
self.assertEqual(result_df.loc[0, "FIELD2"], '["Valid"]')
|
||||
self.assertEqual(result_df.loc[1, "FIELD2"], "")
|
||||
self.assertEqual(result_df.loc[2, "FIELD2"], '["Medicare"]')
|
||||
|
||||
def test_clean_na_values_empty_list_representations(self):
|
||||
"""Test clean_na_values blanks [] and ['[]'] instead of showing them."""
|
||||
input_df = pd.DataFrame(
|
||||
{
|
||||
"FIELD1": ["[]", "['[]']", '["[]"]', "Valid"],
|
||||
"FIELD2": [["[]"], ["Valid"], [], "[]"],
|
||||
}
|
||||
)
|
||||
|
||||
result_df = clean_na_values(input_df)
|
||||
|
||||
self.assertEqual(result_df.loc[0, "FIELD1"], "")
|
||||
self.assertEqual(result_df.loc[1, "FIELD1"], "")
|
||||
self.assertEqual(result_df.loc[2, "FIELD1"], "")
|
||||
self.assertEqual(result_df.loc[3, "FIELD1"], "Valid")
|
||||
|
||||
self.assertEqual(result_df.loc[0, "FIELD2"], "")
|
||||
self.assertEqual(result_df.loc[1, "FIELD2"], ["Valid"])
|
||||
self.assertEqual(result_df.loc[2, "FIELD2"], "")
|
||||
self.assertEqual(result_df.loc[3, "FIELD2"], "")
|
||||
|
||||
def test_clean_na_values_python_lists(self):
|
||||
"""Test clean_na_values handles Python list types."""
|
||||
# Test with Python lists
|
||||
input_df = pd.DataFrame(
|
||||
{
|
||||
"FIELD1": [["N/A"], ["Medicare", "N/A"], ["N/A", "N/A"]],
|
||||
"FIELD2": [["Valid"], ["N/A"], ["Medicare"]],
|
||||
}
|
||||
)
|
||||
|
||||
result_df = clean_na_values(input_df)
|
||||
|
||||
# ["N/A"] should be removed
|
||||
self.assertEqual(result_df.loc[0, "FIELD1"], "")
|
||||
# ["Medicare", "N/A"] should be kept
|
||||
self.assertEqual(result_df.loc[1, "FIELD1"], ["Medicare", "N/A"])
|
||||
# ["N/A", "N/A"] should be removed
|
||||
self.assertEqual(result_df.loc[2, "FIELD1"], "")
|
||||
|
||||
self.assertEqual(result_df.loc[0, "FIELD2"], ["Valid"])
|
||||
self.assertEqual(result_df.loc[1, "FIELD2"], "")
|
||||
self.assertEqual(result_df.loc[2, "FIELD2"], ["Medicare"])
|
||||
|
||||
def test_clean_na_values_none_and_nan(self):
|
||||
"""Test clean_na_values handles None and NaN values."""
|
||||
import numpy as np
|
||||
|
||||
input_df = pd.DataFrame(
|
||||
{
|
||||
"FIELD1": [None, np.nan, pd.NA, "Valid"],
|
||||
"FIELD2": ["", " ", "Valid", None],
|
||||
}
|
||||
)
|
||||
|
||||
result_df = clean_na_values(input_df)
|
||||
|
||||
# None, NaN, pd.NA, empty strings should be removed (converted to empty strings)
|
||||
self.assertEqual(result_df.loc[0, "FIELD1"], "")
|
||||
self.assertEqual(result_df.loc[1, "FIELD1"], "")
|
||||
# pd.NA should be converted to empty string
|
||||
result_val = result_df.loc[2, "FIELD1"]
|
||||
self.assertTrue(
|
||||
result_val == "" or pd.isna(result_val),
|
||||
f"Expected empty string or NA, got: {result_val}",
|
||||
)
|
||||
self.assertEqual(result_df.loc[3, "FIELD1"], "Valid")
|
||||
|
||||
self.assertEqual(result_df.loc[0, "FIELD2"], "")
|
||||
self.assertEqual(result_df.loc[1, "FIELD2"], "")
|
||||
self.assertEqual(result_df.loc[2, "FIELD2"], "Valid")
|
||||
self.assertEqual(result_df.loc[3, "FIELD2"], "")
|
||||
|
||||
def test_clean_na_values_empty_dataframe(self):
|
||||
"""Test clean_na_values handles empty DataFrame."""
|
||||
empty_df = pd.DataFrame()
|
||||
result_df = clean_na_values(empty_df)
|
||||
pd.testing.assert_frame_equal(result_df, empty_df)
|
||||
|
||||
def test_clean_na_values_case_insensitive(self):
|
||||
"""Test clean_na_values is case-insensitive for placeholder matching."""
|
||||
input_df = pd.DataFrame(
|
||||
{
|
||||
"FIELD1": ["n/a", "N/A", "Na", "unknown", "UNKNOWN", "None", "NULL"],
|
||||
}
|
||||
)
|
||||
|
||||
result_df = clean_na_values(input_df)
|
||||
|
||||
# All should be removed (case-insensitive matching)
|
||||
for idx in range(len(result_df)):
|
||||
self.assertEqual(result_df.loc[idx, "FIELD1"], "")
|
||||
|
||||
def test_standard_postprocess_cleans_na_and_normalizes_ind(self):
|
||||
"""Test standard_postprocess removes N/A values and normalizes _IND fields."""
|
||||
constants = Constants()
|
||||
input_df = pd.DataFrame(
|
||||
{
|
||||
"FILE_NAME": ["test1.pdf", "test2.pdf"],
|
||||
"PAYER_NAME": [
|
||||
"N/A",
|
||||
"Valid Value",
|
||||
], # Use actual column from FIELD_FORMAT_MAPPING
|
||||
"AUTO_RENEWAL_IND": ["Y", ""],
|
||||
"DSH_IND": [None, "N"],
|
||||
"IME_IND": [" ", "y"],
|
||||
"NETWORK": ['["N/A"]', '["Medicare"]'],
|
||||
}
|
||||
)
|
||||
|
||||
result_df = standard_postprocess(input_df, constants)
|
||||
|
||||
# N/A should be removed (PAYER_NAME is in FIELD_FORMAT_MAPPING so it will be preserved)
|
||||
self.assertEqual(result_df.loc[0, "PAYER_NAME"], "")
|
||||
self.assertEqual(result_df.loc[1, "PAYER_NAME"], "Valid Value")
|
||||
|
||||
# _IND fields should be normalized to Y or N
|
||||
self.assertEqual(result_df.loc[0, "AUTO_RENEWAL_IND"], "Y")
|
||||
self.assertEqual(result_df.loc[1, "AUTO_RENEWAL_IND"], "N") # Empty becomes N
|
||||
|
||||
self.assertEqual(result_df.loc[0, "DSH_IND"], "N") # None becomes N
|
||||
self.assertEqual(result_df.loc[1, "DSH_IND"], "N")
|
||||
|
||||
self.assertEqual(result_df.loc[0, "IME_IND"], "N") # Whitespace becomes N
|
||||
self.assertEqual(result_df.loc[1, "IME_IND"], "Y") # "y" becomes "Y"
|
||||
|
||||
# JSON list with only N/A should be removed
|
||||
self.assertEqual(result_df.loc[0, "NETWORK"], "")
|
||||
# JSON list with valid value should be kept
|
||||
self.assertEqual(result_df.loc[1, "NETWORK"], '["Medicare"]')
|
||||
|
||||
def test_standard_postprocess_empty_dataframe(self):
|
||||
"""Test standard_postprocess handles empty DataFrame."""
|
||||
constants = Constants()
|
||||
empty_df = pd.DataFrame()
|
||||
result_df = standard_postprocess(empty_df, constants)
|
||||
pd.testing.assert_frame_equal(result_df, empty_df)
|
||||
|
||||
def test_standard_postprocess_cleans_na_without_ind_fields(self):
|
||||
"""Test standard_postprocess cleans N/A values even when no _IND fields are present."""
|
||||
constants = Constants()
|
||||
input_df = pd.DataFrame(
|
||||
{
|
||||
"FILE_NAME": ["test1.pdf", "test2.pdf"],
|
||||
"PAYER_NAME": [
|
||||
"N/A",
|
||||
"Valid",
|
||||
], # Use actual column from FIELD_FORMAT_MAPPING
|
||||
"CONTRACT_TITLE": [
|
||||
"UNKNOWN",
|
||||
"Another Valid",
|
||||
], # Use actual column from FIELD_FORMAT_MAPPING
|
||||
}
|
||||
)
|
||||
|
||||
result_df = standard_postprocess(input_df, constants)
|
||||
|
||||
# Should still clean N/A values (these columns are in FIELD_FORMAT_MAPPING so they will be preserved)
|
||||
self.assertEqual(result_df.loc[0, "PAYER_NAME"], "")
|
||||
self.assertEqual(result_df.loc[1, "PAYER_NAME"], "Valid")
|
||||
self.assertEqual(result_df.loc[0, "CONTRACT_TITLE"], "")
|
||||
self.assertEqual(result_df.loc[1, "CONTRACT_TITLE"], "Another Valid")
|
||||
|
||||
|
||||
class TestOutputFileStructure(unittest.TestCase):
|
||||
"""Test that the new output file structure is generated correctly."""
|
||||
|
||||
def setUp(self):
|
||||
"""Create temporary directory for outputs."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.run_timestamp = "run_20250106_10-30_test"
|
||||
self.batch_id = "test_batch"
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up temporary directory."""
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
@patch("src.utils.io_utils.config")
|
||||
def test_write_local_cc_results_full_structure(self, mock_config):
|
||||
"""Test that cc_results_full creates correct directory structure."""
|
||||
mock_config.BATCH_ID = "test_batch"
|
||||
mock_config.CONSOLIDATED_OUTPUT_DIRECTORY = self.temp_dir
|
||||
mock_config.PERFORM_PARENT_CHILD_MAPPING = False
|
||||
mock_config.MAX_ROWS_PER_SPLIT = 70000
|
||||
|
||||
from src.utils.io_utils import write_local
|
||||
|
||||
# Add FILE_NAME column required for splitting logic
|
||||
test_df = pd.DataFrame(
|
||||
{"FILE_NAME": ["file1.txt", "file2.txt"], "col1": [1, 2], "col2": [3, 4]}
|
||||
)
|
||||
|
||||
write_local(test_df, "", self.run_timestamp, "cc_results_full")
|
||||
|
||||
# Check directory structure was created
|
||||
expected_dir = os.path.join(
|
||||
self.temp_dir, self.run_timestamp, "full_outputs", "cc_results"
|
||||
)
|
||||
self.assertTrue(os.path.exists(expected_dir))
|
||||
|
||||
# Check file was created
|
||||
expected_file = os.path.join(expected_dir, "test_batch-RESULTS-FULL.csv")
|
||||
self.assertTrue(os.path.exists(expected_file))
|
||||
|
||||
@patch("src.utils.io_utils.config")
|
||||
def test_write_local_dashboard_results_full_structure(self, mock_config):
|
||||
"""Test that dashboard_results_full creates correct directory structure."""
|
||||
mock_config.BATCH_ID = "test_batch"
|
||||
mock_config.CONSOLIDATED_OUTPUT_DIRECTORY = self.temp_dir
|
||||
mock_config.PERFORM_PARENT_CHILD_MAPPING = False
|
||||
mock_config.MAX_ROWS_PER_SPLIT = 70000
|
||||
|
||||
from src.utils.io_utils import write_local
|
||||
|
||||
# Add FILE_NAME column required for splitting logic
|
||||
test_df = pd.DataFrame(
|
||||
{"FILE_NAME": ["file1.txt", "file2.txt"], "col1": [1, 2], "col2": [3, 4]}
|
||||
)
|
||||
|
||||
write_local(test_df, "", self.run_timestamp, "dashboard_results_full")
|
||||
|
||||
# Check directory structure was created
|
||||
expected_dir = os.path.join(
|
||||
self.temp_dir, self.run_timestamp, "full_outputs", "dashboard_results"
|
||||
)
|
||||
self.assertTrue(os.path.exists(expected_dir))
|
||||
|
||||
# Check file was created
|
||||
expected_file = os.path.join(expected_dir, "test_batch-RESULTS-dashboard.csv")
|
||||
self.assertTrue(os.path.exists(expected_file))
|
||||
|
||||
@patch("src.utils.io_utils.config")
|
||||
def test_write_local_error_in_full_outputs(self, mock_config):
|
||||
"""Test that error files go to full_outputs/ directory."""
|
||||
mock_config.BATCH_ID = "test_batch"
|
||||
mock_config.CONSOLIDATED_OUTPUT_DIRECTORY = self.temp_dir
|
||||
mock_config.PERFORM_PARENT_CHILD_MAPPING = False
|
||||
|
||||
from src.utils.io_utils import write_local
|
||||
|
||||
test_df = pd.DataFrame({"error": ["Error 1", "Error 2"]})
|
||||
|
||||
write_local(test_df, "", self.run_timestamp, "error")
|
||||
|
||||
# Check directory structure was created
|
||||
expected_dir = os.path.join(self.temp_dir, self.run_timestamp, "full_outputs")
|
||||
self.assertTrue(os.path.exists(expected_dir))
|
||||
|
||||
# Check file was created
|
||||
expected_file = os.path.join(expected_dir, "test_batch-ERRORS.csv")
|
||||
self.assertTrue(os.path.exists(expected_file))
|
||||
|
||||
@patch("src.utils.io_utils.config")
|
||||
def test_write_local_qc_qa_cc_full_structure(self, mock_config):
|
||||
"""Test that qc_qa_cc_full creates correct directory structure."""
|
||||
mock_config.BATCH_ID = "test_batch"
|
||||
mock_config.CONSOLIDATED_OUTPUT_DIRECTORY = self.temp_dir
|
||||
mock_config.PERFORM_PARENT_CHILD_MAPPING = False
|
||||
mock_config.MAX_ROWS_PER_SPLIT = 70000
|
||||
|
||||
from src.utils.io_utils import write_local
|
||||
|
||||
# Add FILE_NAME column required for splitting logic
|
||||
test_df = pd.DataFrame(
|
||||
{"FILE_NAME": ["file1.txt", "file2.txt"], "col1": [1, 2], "col2": [3, 4]}
|
||||
)
|
||||
|
||||
write_local(test_df, "", self.run_timestamp, "qc_qa_cc_full")
|
||||
|
||||
# Check directory structure was created
|
||||
expected_dir = os.path.join(
|
||||
self.temp_dir, self.run_timestamp, "automation_qa-qc"
|
||||
)
|
||||
self.assertTrue(os.path.exists(expected_dir))
|
||||
|
||||
# Check file was created
|
||||
expected_file = os.path.join(expected_dir, "test_batch-RESULTS-QC-QA-FULL.csv")
|
||||
self.assertTrue(os.path.exists(expected_file))
|
||||
|
||||
@patch("src.utils.io_utils.config")
|
||||
def test_write_local_qc_qa_stats_structure(self, mock_config):
|
||||
"""Test that qc_qa_stats creates correct directory structure."""
|
||||
mock_config.BATCH_ID = "test_batch"
|
||||
mock_config.CONSOLIDATED_OUTPUT_DIRECTORY = self.temp_dir
|
||||
mock_config.PERFORM_PARENT_CHILD_MAPPING = False
|
||||
|
||||
from src.utils.io_utils import write_local
|
||||
|
||||
test_df = pd.DataFrame({"stat": ["value1", "value2"]})
|
||||
|
||||
write_local(test_df, "", self.run_timestamp, "qc_qa_stats")
|
||||
|
||||
# Check directory structure was created
|
||||
expected_dir = os.path.join(
|
||||
self.temp_dir, self.run_timestamp, "automation_qa-qc"
|
||||
)
|
||||
self.assertTrue(os.path.exists(expected_dir))
|
||||
|
||||
# Check file was created
|
||||
expected_file = os.path.join(expected_dir, "test_batch-QC-QA-STATS.csv")
|
||||
self.assertTrue(os.path.exists(expected_file))
|
||||
|
||||
@patch("src.utils.io_utils.config")
|
||||
def test_write_local_parent_child_structure(self, mock_config):
|
||||
"""Test that parent_child creates correct directory structure and file path."""
|
||||
mock_config.BATCH_ID = "test_batch"
|
||||
mock_config.CONSOLIDATED_OUTPUT_DIRECTORY = self.temp_dir
|
||||
mock_config.PERFORM_PARENT_CHILD_MAPPING = False
|
||||
|
||||
from src.utils.io_utils import write_local
|
||||
|
||||
test_df = pd.DataFrame({"col1": [1, 2]})
|
||||
|
||||
# Mock to_csv on the DataFrame instance to avoid actual file I/O
|
||||
test_df.to_csv = MagicMock()
|
||||
|
||||
write_local(test_df, "", self.run_timestamp, "parent_child")
|
||||
|
||||
# Verify to_csv was called on the DataFrame
|
||||
test_df.to_csv.assert_called_once()
|
||||
# Verify the call had index=False and quoting=1
|
||||
call_kwargs = test_df.to_csv.call_args[1]
|
||||
self.assertEqual(call_kwargs.get("index"), False)
|
||||
self.assertEqual(call_kwargs.get("quoting"), 1)
|
||||
|
||||
# Verify the file path contains the expected components
|
||||
call_args = test_df.to_csv.call_args[0]
|
||||
file_path = call_args[0] if call_args else None
|
||||
self.assertIsNotNone(file_path, "to_csv should be called with a file path")
|
||||
self.assertIn(
|
||||
"parent-child",
|
||||
file_path,
|
||||
f"File path should contain 'parent-child', got: {file_path}",
|
||||
)
|
||||
self.assertIn(
|
||||
"test_batch-PC.csv",
|
||||
file_path,
|
||||
f"File path should contain 'test_batch-PC.csv', got: {file_path}",
|
||||
)
|
||||
|
||||
# Verify the directory structure would be correct
|
||||
expected_dir = os.path.join(self.temp_dir, self.run_timestamp, "parent-child")
|
||||
self.assertIn(
|
||||
expected_dir,
|
||||
file_path,
|
||||
f"File path should contain expected directory {expected_dir}, got: {file_path}",
|
||||
)
|
||||
|
||||
@patch("src.utils.io_utils.config")
|
||||
@patch("src.utils.io_utils.logging")
|
||||
def test_write_s3_cc_results_full_path(self, mock_logging, mock_config):
|
||||
"""Test that write_s3 uses correct S3 path for cc_results_full."""
|
||||
mock_config.BATCH_ID = "test_batch"
|
||||
mock_config.S3_OUTPUT_BUCKET = "test-bucket"
|
||||
mock_config.S3_CLIENT = MagicMock()
|
||||
mock_config.S3_CLIENT.put_object = MagicMock()
|
||||
mock_config.PERFORM_PARENT_CHILD_MAPPING = False
|
||||
mock_config.MAX_ROWS_PER_SPLIT = 70000
|
||||
|
||||
from src.utils.io_utils import write_s3
|
||||
|
||||
# Add FILE_NAME column required for splitting logic
|
||||
test_df = pd.DataFrame(
|
||||
{"FILE_NAME": ["file1.txt", "file2.txt"], "col1": [1, 2]}
|
||||
)
|
||||
|
||||
write_s3(test_df, "", self.run_timestamp, "cc_results_full")
|
||||
|
||||
# Verify S3 put_object was called
|
||||
mock_config.S3_CLIENT.put_object.assert_called_once()
|
||||
|
||||
# Verify the S3 key/path
|
||||
call_args = mock_config.S3_CLIENT.put_object.call_args
|
||||
s3_key = call_args[1]["Key"]
|
||||
expected_key = f"test_batch/{self.run_timestamp}/full_outputs/cc_results/test_batch-RESULTS-FULL.csv"
|
||||
self.assertEqual(s3_key, expected_key)
|
||||
|
||||
@patch("src.utils.io_utils.config")
|
||||
@patch("src.utils.io_utils.logging")
|
||||
def test_write_s3_dashboard_results_full_path(self, mock_logging, mock_config):
|
||||
"""Test that write_s3 uses correct S3 path for dashboard_results_full."""
|
||||
mock_config.BATCH_ID = "test_batch"
|
||||
mock_config.S3_OUTPUT_BUCKET = "test-bucket"
|
||||
mock_config.S3_CLIENT = MagicMock()
|
||||
mock_config.S3_CLIENT.put_object = MagicMock()
|
||||
mock_config.PERFORM_PARENT_CHILD_MAPPING = False
|
||||
mock_config.MAX_ROWS_PER_SPLIT = 70000
|
||||
|
||||
from src.utils.io_utils import write_s3
|
||||
|
||||
# Add FILE_NAME column required for splitting logic
|
||||
test_df = pd.DataFrame(
|
||||
{"FILE_NAME": ["file1.txt", "file2.txt"], "col1": [1, 2]}
|
||||
)
|
||||
|
||||
write_s3(test_df, "", self.run_timestamp, "dashboard_results_full")
|
||||
|
||||
# Verify S3 put_object was called
|
||||
mock_config.S3_CLIENT.put_object.assert_called_once()
|
||||
|
||||
# Verify the S3 key/path
|
||||
call_args = mock_config.S3_CLIENT.put_object.call_args
|
||||
s3_key = call_args[1]["Key"]
|
||||
expected_key = f"test_batch/{self.run_timestamp}/full_outputs/dashboard_results/test_batch-RESULTS-dashboard.csv"
|
||||
self.assertEqual(s3_key, expected_key)
|
||||
|
||||
@patch("src.utils.io_utils.config")
|
||||
@patch("src.utils.io_utils.logging")
|
||||
def test_write_s3_qc_qa_paths(self, mock_logging, mock_config):
|
||||
"""Test that write_s3 uses correct S3 paths for QC/QA outputs."""
|
||||
mock_config.BATCH_ID = "test_batch"
|
||||
mock_config.S3_OUTPUT_BUCKET = "test-bucket"
|
||||
mock_config.S3_CLIENT = MagicMock()
|
||||
mock_config.S3_CLIENT.put_object = MagicMock()
|
||||
mock_config.PERFORM_PARENT_CHILD_MAPPING = False
|
||||
mock_config.MAX_ROWS_PER_SPLIT = 70000
|
||||
|
||||
from src.utils.io_utils import write_s3
|
||||
|
||||
# Add FILE_NAME column required for splitting logic
|
||||
test_df = pd.DataFrame(
|
||||
{"FILE_NAME": ["file1.txt", "file2.txt"], "col1": [1, 2]}
|
||||
)
|
||||
|
||||
# Test qc_qa_cc_full
|
||||
write_s3(test_df, "", self.run_timestamp, "qc_qa_cc_full")
|
||||
call_args = mock_config.S3_CLIENT.put_object.call_args
|
||||
s3_key = call_args[1]["Key"]
|
||||
expected_key = f"test_batch/{self.run_timestamp}/automation_qa-qc/test_batch-RESULTS-QC-QA-FULL.csv"
|
||||
self.assertEqual(s3_key, expected_key)
|
||||
|
||||
# Test qc_qa_stats
|
||||
mock_config.S3_CLIENT.put_object.reset_mock()
|
||||
test_df_stats = pd.DataFrame(
|
||||
{"col1": [1, 2]}
|
||||
) # qc_qa_stats doesn't need FILE_NAME
|
||||
write_s3(test_df_stats, "", self.run_timestamp, "qc_qa_stats")
|
||||
call_args = mock_config.S3_CLIENT.put_object.call_args
|
||||
s3_key = call_args[1]["Key"]
|
||||
expected_key = f"test_batch/{self.run_timestamp}/automation_qa-qc/test_batch-QC-QA-STATS.csv"
|
||||
self.assertEqual(s3_key, expected_key)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+414
-28
@@ -338,6 +338,163 @@ def read_xlsb(
|
||||
return df
|
||||
|
||||
|
||||
def split_dataframe_by_filename(
|
||||
df: pd.DataFrame, max_rows_per_split: int = 70000
|
||||
) -> list[pd.DataFrame]:
|
||||
"""
|
||||
Split a DataFrame into multiple DataFrames based on filename groups.
|
||||
|
||||
This function ensures that all rows from the same filename stay together.
|
||||
It sorts filenames by row count (ascending) and creates splits that are as
|
||||
close to max_rows_per_split as possible without breaking up filename groups.
|
||||
|
||||
Args:
|
||||
df (pd.DataFrame): The DataFrame to split. Must have a 'FILE_NAME' column.
|
||||
max_rows_per_split (int): Maximum number of rows per split file. Default: 70000.
|
||||
|
||||
Returns:
|
||||
list[pd.DataFrame]: List of DataFrames, each representing a split file.
|
||||
|
||||
Raises:
|
||||
ValueError: If DataFrame doesn't have 'FILE_NAME' column or is empty.
|
||||
"""
|
||||
if df.empty:
|
||||
return [df]
|
||||
|
||||
if "FILE_NAME" not in df.columns:
|
||||
raise ValueError("DataFrame must have a 'FILE_NAME' column for splitting")
|
||||
|
||||
# Count rows per filename
|
||||
filename_counts = df["FILE_NAME"].value_counts().sort_values(ascending=True)
|
||||
filename_order = filename_counts.index.tolist()
|
||||
|
||||
# Create list to store split DataFrames
|
||||
splits = []
|
||||
current_split_rows = []
|
||||
current_split_count = 0
|
||||
|
||||
for filename in filename_order:
|
||||
filename_df = df[df["FILE_NAME"] == filename]
|
||||
filename_row_count = len(filename_df)
|
||||
|
||||
# If adding this filename would exceed the limit, start a new split
|
||||
if (
|
||||
current_split_count + filename_row_count > max_rows_per_split
|
||||
and current_split_rows
|
||||
):
|
||||
# Create and save current split
|
||||
current_split = pd.concat(current_split_rows, ignore_index=True)
|
||||
splits.append(current_split)
|
||||
# Start new split with current filename
|
||||
current_split_rows = [filename_df]
|
||||
current_split_count = filename_row_count
|
||||
else:
|
||||
# Add filename to current split
|
||||
current_split_rows.append(filename_df)
|
||||
current_split_count += filename_row_count
|
||||
|
||||
# Add the last split if it has any rows
|
||||
if current_split_rows:
|
||||
final_split = pd.concat(current_split_rows, ignore_index=True)
|
||||
splits.append(final_split)
|
||||
|
||||
return splits if splits else [df]
|
||||
|
||||
|
||||
def _write_local_split_files(
|
||||
df: pd.DataFrame,
|
||||
output_dir: str,
|
||||
full_filename: str,
|
||||
split_filename_template: str,
|
||||
return_path_for_pc: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Helper function to write split files locally.
|
||||
|
||||
Args:
|
||||
df: DataFrame to write (will be split if needed)
|
||||
output_dir: Full output directory path
|
||||
full_filename: Filename for single file (e.g., "{BATCH_ID}-RESULTS-FULL.csv")
|
||||
split_filename_template: Template for split files (e.g., "{BATCH_ID}-RESULTS-SPLIT{idx}.csv")
|
||||
return_path_for_pc: Whether to return path for parent-child mapping
|
||||
|
||||
Returns:
|
||||
Full file path string if return_path_for_pc is True, None otherwise
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
splits = split_dataframe_by_filename(df, config.MAX_ROWS_PER_SPLIT)
|
||||
|
||||
if len(splits) == 1:
|
||||
output_path = os.path.join(output_dir, full_filename)
|
||||
splits[0].to_csv(output_path, index=False, quoting=1)
|
||||
return output_path if return_path_for_pc else None
|
||||
else:
|
||||
# Multiple splits - write numbered files
|
||||
for idx, split_df in enumerate(splits, start=1):
|
||||
split_filename = split_filename_template.format(idx=idx)
|
||||
split_output_path = os.path.join(output_dir, split_filename)
|
||||
split_df.to_csv(split_output_path, index=False, quoting=1)
|
||||
# For parent-child mapping, use the first split file
|
||||
if return_path_for_pc:
|
||||
first_split_filename = split_filename_template.format(idx=1)
|
||||
return os.path.join(output_dir, first_split_filename)
|
||||
return None
|
||||
|
||||
|
||||
def _write_s3_split_files(
|
||||
df: pd.DataFrame,
|
||||
run_timestamp: str,
|
||||
base_path: str,
|
||||
full_filename: str,
|
||||
split_filename_template: str,
|
||||
return_path_for_pc: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Helper function to write split files to S3.
|
||||
|
||||
Args:
|
||||
df: DataFrame to write (will be split if needed)
|
||||
run_timestamp: Timestamp string for path
|
||||
base_path: Base S3 path (e.g., "full_outputs/cc_results")
|
||||
full_filename: Filename for single file (e.g., "{BATCH_ID}-RESULTS-FULL.csv")
|
||||
split_filename_template: Template for split files (e.g., "{BATCH_ID}-RESULTS-SPLIT{idx}.csv")
|
||||
return_path_for_pc: Whether to return path for parent-child mapping
|
||||
|
||||
Returns:
|
||||
S3 path string if return_path_for_pc is True, None otherwise
|
||||
"""
|
||||
splits = split_dataframe_by_filename(df, config.MAX_ROWS_PER_SPLIT)
|
||||
|
||||
if len(splits) == 1:
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/{base_path}/{full_filename}"
|
||||
csv_buffer = splits[0].to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
logging.info(f"Saved {base_path.split('/')[-1]} full file: {output_path}")
|
||||
return output_path if return_path_for_pc else None
|
||||
else:
|
||||
# Multiple splits - upload each one
|
||||
for idx, split_df in enumerate(splits, start=1):
|
||||
split_filename = split_filename_template.format(idx=idx)
|
||||
split_output_path = (
|
||||
f"{config.BATCH_ID}/{run_timestamp}/{base_path}/{split_filename}"
|
||||
)
|
||||
csv_buffer = split_df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=split_output_path, Body=csv_buffer
|
||||
)
|
||||
logging.info(
|
||||
f"Saved {base_path.split('/')[-1]} split files ({len(splits)} files)"
|
||||
)
|
||||
if return_path_for_pc:
|
||||
first_split_filename = split_filename_template.format(idx=1)
|
||||
return (
|
||||
f"{config.BATCH_ID}/{run_timestamp}/{base_path}/{first_split_filename}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def write_local(
|
||||
df: pd.DataFrame, filename: str, run_timestamp: str, output_type: str
|
||||
) -> Optional[str]:
|
||||
@@ -386,18 +543,12 @@ def write_local(
|
||||
config.OUTPUT_DIRECTORY, f"{base_filename}_-RESULTS.csv"
|
||||
)
|
||||
|
||||
return None
|
||||
elif output_type == "parent_child":
|
||||
output_dir = os.path.join(config.CONSOLIDATED_OUTPUT_DIRECTORY, run_timestamp)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
df.to_csv(
|
||||
os.path.join(output_dir, f"{config.BATCH_ID}-Parent_Child.csv"),
|
||||
index=False,
|
||||
quoting=1,
|
||||
)
|
||||
return None
|
||||
elif output_type == "error":
|
||||
output_dir = os.path.join(config.CONSOLIDATED_OUTPUT_DIRECTORY, run_timestamp)
|
||||
# Error file goes in full_outputs/ folder
|
||||
output_dir = os.path.join(
|
||||
config.CONSOLIDATED_OUTPUT_DIRECTORY, run_timestamp, "full_outputs"
|
||||
)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
df.to_csv(
|
||||
os.path.join(output_dir, f"{config.BATCH_ID}-ERRORS.csv"),
|
||||
@@ -407,6 +558,74 @@ def write_local(
|
||||
if config.PERFORM_PARENT_CHILD_MAPPING:
|
||||
return os.path.join(output_dir, f"{config.BATCH_ID}-RESULTS.csv")
|
||||
return None
|
||||
elif output_type == "cc_results_full":
|
||||
# Full CC results (may be split into multiple files)
|
||||
output_dir = os.path.join(
|
||||
config.CONSOLIDATED_OUTPUT_DIRECTORY,
|
||||
run_timestamp,
|
||||
"full_outputs",
|
||||
"cc_results",
|
||||
)
|
||||
return _write_local_split_files(
|
||||
df=df,
|
||||
output_dir=output_dir,
|
||||
full_filename=f"{config.BATCH_ID}-RESULTS-FULL.csv",
|
||||
split_filename_template=f"{config.BATCH_ID}-RESULTS-SPLIT{{idx}}.csv",
|
||||
return_path_for_pc=config.PERFORM_PARENT_CHILD_MAPPING,
|
||||
)
|
||||
elif output_type == "dashboard_results_full":
|
||||
# Full dashboard results (may be split into multiple files)
|
||||
output_dir = os.path.join(
|
||||
config.CONSOLIDATED_OUTPUT_DIRECTORY,
|
||||
run_timestamp,
|
||||
"full_outputs",
|
||||
"dashboard_results",
|
||||
)
|
||||
_write_local_split_files(
|
||||
df=df,
|
||||
output_dir=output_dir,
|
||||
full_filename=f"{config.BATCH_ID}-RESULTS-dashboard.csv",
|
||||
split_filename_template=f"{config.BATCH_ID}-RESULTS-dashboard-SPLIT{{idx}}.csv",
|
||||
return_path_for_pc=False,
|
||||
)
|
||||
return None
|
||||
elif output_type == "qc_qa_cc_full":
|
||||
# QC/QA CC full results (may be split into multiple files)
|
||||
output_dir = os.path.join(
|
||||
config.CONSOLIDATED_OUTPUT_DIRECTORY, run_timestamp, "automation_qa-qc"
|
||||
)
|
||||
_write_local_split_files(
|
||||
df=df,
|
||||
output_dir=output_dir,
|
||||
full_filename=f"{config.BATCH_ID}-RESULTS-QC-QA-FULL.csv",
|
||||
split_filename_template=f"{config.BATCH_ID}-RESULTS-QC-QA-SPLIT{{idx}}.csv",
|
||||
return_path_for_pc=False,
|
||||
)
|
||||
return None
|
||||
elif output_type == "qc_qa_error":
|
||||
# QC/QA error results
|
||||
output_dir = os.path.join(
|
||||
config.CONSOLIDATED_OUTPUT_DIRECTORY, run_timestamp, "automation_qa-qc"
|
||||
)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
df.to_csv(
|
||||
os.path.join(output_dir, f"{config.BATCH_ID}-ERRORS-QC-QA.csv"),
|
||||
index=False,
|
||||
quoting=1,
|
||||
)
|
||||
return None
|
||||
elif output_type == "parent_child":
|
||||
# Parent-child output
|
||||
output_dir = os.path.join(
|
||||
config.CONSOLIDATED_OUTPUT_DIRECTORY, run_timestamp, "parent-child"
|
||||
)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
df.to_csv(
|
||||
os.path.join(output_dir, f"{config.BATCH_ID}-PC.csv"),
|
||||
index=False,
|
||||
quoting=1,
|
||||
)
|
||||
return None
|
||||
elif output_type == "usage":
|
||||
output_dir = os.path.join(
|
||||
config.CONSOLIDATED_OUTPUT_DIRECTORY, run_timestamp, "tracking"
|
||||
@@ -458,8 +677,10 @@ def write_local(
|
||||
)
|
||||
return None
|
||||
elif output_type == "qc_qa_stats":
|
||||
# QC/QA statistics output goes to outputs/qc_qa folder
|
||||
output_dir = os.path.join(config.QC_QA_OUTPUT_DIRECTORY, run_timestamp)
|
||||
# QC/QA statistics output goes to automation_qa-qc folder
|
||||
output_dir = os.path.join(
|
||||
config.CONSOLIDATED_OUTPUT_DIRECTORY, run_timestamp, "automation_qa-qc"
|
||||
)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
df.to_csv(
|
||||
os.path.join(output_dir, f"{config.BATCH_ID}-QC-QA-STATS.csv"),
|
||||
@@ -467,9 +688,22 @@ def write_local(
|
||||
quoting=1,
|
||||
)
|
||||
return None
|
||||
elif output_type == "individual_cc":
|
||||
# Individual CC results
|
||||
base_filename = os.path.splitext(filename)[0].strip()
|
||||
output_dir = os.path.join(
|
||||
config.CONSOLIDATED_OUTPUT_DIRECTORY, run_timestamp, "individual"
|
||||
)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
df.to_csv(
|
||||
os.path.join(output_dir, f"{base_filename}-RESULTS-CC.csv"),
|
||||
index=False,
|
||||
quoting=1,
|
||||
)
|
||||
return None
|
||||
else:
|
||||
logging.error(
|
||||
f"Unknown output_type: {output_type}. Valid types are: 'final', 'individual', 'error', 'usage', 'usage_summary', 'dtc', 'dtc_summary', 'qc_qa_validated', 'qc_qa_stats'"
|
||||
f"Unknown output_type: {output_type}. Valid types are: 'final', 'individual', 'error', 'usage', 'usage_summary', 'dtc', 'dtc_summary', 'qc_qa_validated', 'qc_qa_stats', 'cc_results_full', 'dashboard_results_full', 'qc_qa_cc_full', 'qc_qa_error', 'parent_child', 'individual_cc'"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -507,42 +741,144 @@ def write_s3(
|
||||
"""
|
||||
if output_type == "final":
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/{config.BATCH_ID}-RESULTS.csv"
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
try:
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
except ClientError as e:
|
||||
logging.error(f"Error uploading to S3: {e}")
|
||||
return None
|
||||
elif output_type == "individual":
|
||||
output_path = (
|
||||
f"{config.BATCH_ID}/{run_timestamp}/individual/{filename}-RESULTS.csv"
|
||||
)
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
elif output_type == "individual_cc":
|
||||
output_path = (
|
||||
f"{config.BATCH_ID}/{run_timestamp}/individual/{filename}-RESULTS-CC.csv"
|
||||
)
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
elif output_type == "parent_child":
|
||||
output_path = (
|
||||
f"{config.BATCH_ID}/{run_timestamp}/parent_child/{filename}-RESULTS.csv"
|
||||
f"{config.BATCH_ID}/{run_timestamp}/parent-child/{config.BATCH_ID}-PC.csv"
|
||||
)
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
elif output_type == "error":
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/{config.BATCH_ID}-ERRORS.csv"
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/full_outputs/{config.BATCH_ID}-ERRORS.csv"
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
try:
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
except ClientError as e:
|
||||
logging.error(f"Error uploading to S3: {e}")
|
||||
return None
|
||||
elif output_type in ("cc_results_full", "dashboard_results_full", "qc_qa_cc_full"):
|
||||
# Will be handled in try block using helper function
|
||||
output_path = None
|
||||
parent_child_path = None if output_type == "cc_results_full" else None
|
||||
elif output_type == "qc_qa_error":
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/automation_qa-qc/{config.BATCH_ID}-ERRORS-QC-QA.csv"
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
elif output_type == "usage":
|
||||
output_path = (
|
||||
f"{config.BATCH_ID}/{run_timestamp}/tracking/{config.BATCH_ID}-USAGE.csv"
|
||||
)
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
elif output_type == "usage_summary":
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/tracking/{config.BATCH_ID}-USAGE-SUMMARY.csv"
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
elif output_type == "dtc":
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/{config.BATCH_ID}-DTC.csv"
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
elif output_type == "dtc_summary":
|
||||
output_path = (
|
||||
f"{config.BATCH_ID}/{run_timestamp}/{config.BATCH_ID}-DTC-SUMMARY.csv"
|
||||
)
|
||||
elif output_type == "qc_qa_validated":
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/automation_qa-qc/{config.BATCH_ID}-QC-QA-VALIDATED.csv"
|
||||
elif output_type == "qc_qa_stats":
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/automation_qa-qc/{config.BATCH_ID}-QC-QA-STATS.csv"
|
||||
else:
|
||||
# Fallback path for unknown output types
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/{config.BATCH_ID}-unknown-{output_type}.csv"
|
||||
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
try:
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
if output_type == "final":
|
||||
elif output_type == "qc_qa_validated":
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/automation_qa-qc/{config.BATCH_ID}-QC-QA-VALIDATED.csv"
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
elif output_type == "qc_qa_stats":
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/automation_qa-qc/{config.BATCH_ID}-QC-QA-STATS.csv"
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
elif output_type == "individual_cc":
|
||||
output_path = (
|
||||
f"{config.BATCH_ID}/{run_timestamp}/individual/{filename}-RESULTS-CC.csv"
|
||||
)
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
else:
|
||||
# Fallback path for unknown output types
|
||||
output_path = f"{config.BATCH_ID}/{run_timestamp}/{config.BATCH_ID}-unknown-{output_type}.csv"
|
||||
csv_buffer = df.to_csv(index=False).encode()
|
||||
config.S3_CLIENT.put_object(
|
||||
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
|
||||
)
|
||||
|
||||
try:
|
||||
# Handle split files for cc_results_full, dashboard_results_full, and qc_qa_cc_full
|
||||
if output_type == "cc_results_full":
|
||||
parent_child_path = _write_s3_split_files(
|
||||
df=df,
|
||||
run_timestamp=run_timestamp,
|
||||
base_path="full_outputs/cc_results",
|
||||
full_filename=f"{config.BATCH_ID}-RESULTS-FULL.csv",
|
||||
split_filename_template=f"{config.BATCH_ID}-RESULTS-SPLIT{{idx}}.csv",
|
||||
return_path_for_pc=config.PERFORM_PARENT_CHILD_MAPPING,
|
||||
)
|
||||
elif output_type == "dashboard_results_full":
|
||||
_write_s3_split_files(
|
||||
df=df,
|
||||
run_timestamp=run_timestamp,
|
||||
base_path="full_outputs/dashboard_results",
|
||||
full_filename=f"{config.BATCH_ID}-RESULTS-dashboard.csv",
|
||||
split_filename_template=f"{config.BATCH_ID}-RESULTS-dashboard-SPLIT{{idx}}.csv",
|
||||
return_path_for_pc=False,
|
||||
)
|
||||
elif output_type == "qc_qa_cc_full":
|
||||
_write_s3_split_files(
|
||||
df=df,
|
||||
run_timestamp=run_timestamp,
|
||||
base_path="automation_qa-qc",
|
||||
full_filename=f"{config.BATCH_ID}-RESULTS-QC-QA-FULL.csv",
|
||||
split_filename_template=f"{config.BATCH_ID}-RESULTS-QC-QA-SPLIT{{idx}}.csv",
|
||||
return_path_for_pc=False,
|
||||
)
|
||||
elif output_type == "final":
|
||||
logging.info(f"Saved batch output file: {output_path}")
|
||||
elif output_type == "individual":
|
||||
logging.info(f"Saved individual output file: {output_path}")
|
||||
@@ -562,11 +898,16 @@ def write_s3(
|
||||
logging.info(f"Saved QC/QA validated data file: {output_path}")
|
||||
elif output_type == "qc_qa_stats":
|
||||
logging.info(f"Saved QC/QA statistics file: {output_path}")
|
||||
elif output_type == "qc_qa_error":
|
||||
logging.info(f"Saved QC/QA error file: {output_path}")
|
||||
elif output_type == "individual_cc":
|
||||
logging.info(f"Saved individual CC file: {output_path}")
|
||||
else:
|
||||
logging.info(f"Saved unknown output type file: {output_path}")
|
||||
|
||||
if config.PERFORM_PARENT_CHILD_MAPPING:
|
||||
return f"s3://{config.S3_OUTPUT_BUCKET}/{output_path}"
|
||||
if config.PERFORM_PARENT_CHILD_MAPPING and output_type == "cc_results_full":
|
||||
if "parent_child_path" in locals() and parent_child_path:
|
||||
return f"s3://{config.S3_OUTPUT_BUCKET}/{parent_child_path}"
|
||||
|
||||
return None
|
||||
except ClientError as e:
|
||||
@@ -574,6 +915,51 @@ def write_s3(
|
||||
return None
|
||||
|
||||
|
||||
def upload_local_file_to_s3(
|
||||
local_path: str,
|
||||
run_timestamp: str,
|
||||
s3_subpath: str,
|
||||
content_type: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Upload a local file to S3 using the same path structure as other pipeline outputs.
|
||||
|
||||
Args:
|
||||
local_path: Full path to the local file.
|
||||
run_timestamp: Run timestamp (e.g. run_20260212_13-53_test_batch).
|
||||
s3_subpath: Subpath under run_timestamp (e.g. parent-child).
|
||||
content_type: Optional MIME type. Inferred from extension if not provided.
|
||||
|
||||
Returns:
|
||||
S3 URI on success, None on failure.
|
||||
"""
|
||||
if not os.path.isfile(local_path):
|
||||
logging.error(f"Cannot upload: file not found: {local_path}")
|
||||
return None
|
||||
filename = os.path.basename(local_path)
|
||||
s3_key = f"{config.BATCH_ID}/{run_timestamp}/{s3_subpath}/{filename}"
|
||||
if content_type is None and filename.lower().endswith(".xlsx"):
|
||||
content_type = (
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
try:
|
||||
with open(local_path, "rb") as f:
|
||||
put_kwargs = {
|
||||
"Bucket": config.S3_OUTPUT_BUCKET,
|
||||
"Key": s3_key,
|
||||
"Body": f.read(),
|
||||
}
|
||||
if content_type:
|
||||
put_kwargs["ContentType"] = content_type
|
||||
config.S3_CLIENT.put_object(**put_kwargs)
|
||||
s3_uri = f"s3://{config.S3_OUTPUT_BUCKET}/{s3_key}"
|
||||
logging.info(f"Uploaded file to S3: {s3_uri}")
|
||||
return s3_uri
|
||||
except ClientError as e:
|
||||
logging.error(f"Failed to upload {local_path} to S3: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_result_to_json(filename, result, json_folder):
|
||||
"""Save a result dictionary to an individual JSON file in the specified folder.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user