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
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user