Merged in bugfix/parser-downstream-improvements (pull request #875)
Bugfix/parser downstream improvements * Refactor: Implement field-aware JSON parsers with centralized normalization This refactor introduces a robust system for normalizing LLM output based on field format mappings, ensuring consistent data types throughout the pipeline. Key Changes: - Add FIELD_FORMAT_MAPPING constant defining expected formats for all fields - Create format_normalization.py utility for type-aware normalization - Update json_utils.py parsers to accept field_names/field_name parameters - Refactor prompt_templates.py to use parser factories (_create_json_dict_parser, _create_json_list_parser) that bind field metadata for automatic normalization - Update prompt_calls.py to pass field names to parsers, eliminating redundant normalization logic - Remove parse_json_dict_or_list (unused, ambiguous function) - Simplify METHODOLOGY_BREAKOUT and REIMBURSEMENT_PRIMARY to use helper functions - Add comprehensive integration tests verifying normalization works end-to-end Benefits: - Single source of truth for field formats (FIELD_FORMAT_… * refactor: normalize helper prompt outputs at prompt_calls level - Update CARVEOUT_CHECK to use field-aware parser for CARVEOUT_CD normalization - Update LOB_RELATIONSHIP to normalize to string format in prompt_calls.py - Update SPLIT_REIMB_DATES to normalize date values in prompt_calls.py - Remove defensive normalization from one_to_n_funcs.py for LOB relationships - Remove manual normalization from split_reimb_dates() - values now normalized upstream - All helper prompts that populate fields now normalize at prompt_calls.py level - Downstream functions receive correctly formatted values without additional processing * refactor: remove band-aid normalization functions and migrate HSC to field-aware parsers - Update ONE_TO_ONE_SINGLE_FIELD_TEMPLATE to use field-aware parser with field_name parameter - Remove list wrapping logic in hybrid_smart_chunking_funcs (field-aware parser handles normalization) - Remove normalize_one_to_one_field_value and normalize_one_to_one_answers_dict from string_utils.py - Remove all debug print statements from HSC processing - Remove commented-out normalization calls from client-specific files (clover, bcbs_promise) - All normalization now handled exclusively through FIELD_FORMAT_MAPPING via field-aware parsers * Ran Black for formatting * Print Statements removed, more cleaning * Merge branch 'DEV' into bugfix/parser-downstream-improvements * refactor: combine FIELD_FORMAT_MAPPING into investment_columns.py - Merged field_format_mapping.py into investment_columns.py to create single source of truth - FIELD_FORMAT_MAPPING now ordered by COLUMN_ORDER (161 fields) - Added 5 missing fields from COLUMN_ORDER with default format types - Updated all imports across codebase to use investment_columns - Python dict preserves insertion order (3.7+), maintaining COLUMN_ORDER sequence - All tests passing (38 field-aware tests verified) * Deprecate COLUMN_ORDER, rely on Mapping only * Merged DEV into bugfix/parser-downstream-improvements Approved-by: Katon Minhas
This commit is contained in:
committed by
Katon Minhas
parent
4587042c43
commit
9b0a344b13
+185
-164
@@ -1,164 +1,185 @@
|
||||
# This is the ONE AND ONLY source of investment column names and order. Do not reference anything else
|
||||
COLUMN_ORDER = [
|
||||
"AARETE_DERIVED_SID",
|
||||
"CONTRACT_CLASS",
|
||||
"AARETE_DERIVED_CONTRACT_CLASS",
|
||||
"FILE_NAME",
|
||||
"CONTRACT_TITLE",
|
||||
"CONTRACT_AMENDMENT_NUM",
|
||||
"FILENAME_AMENDMENT_NUM",
|
||||
"AARETE_DERIVED_AMENDMENT_NUM",
|
||||
"CLIENT_NAME",
|
||||
"PAYER_NAME",
|
||||
"PAYER_STATE",
|
||||
"PROVIDER_STATE",
|
||||
"FILENAME_TIN",
|
||||
"PROV_GROUP_TIN",
|
||||
"PROV_GROUP_NPI",
|
||||
"PROV_GROUP_NAME_FULL",
|
||||
"PROV_OTHER_TIN",
|
||||
"PROV_OTHER_NPI",
|
||||
"PROV_OTHER_NAME_FULL",
|
||||
"PROV_INFO_JSON",
|
||||
"EFFECTIVE_DT",
|
||||
"AARETE_DERIVED_EFFECTIVE_DT",
|
||||
"TERMINATION_DT",
|
||||
"AARETE_DERIVED_TERMINATION_DT",
|
||||
"AUTO_RENEWAL_IND",
|
||||
"AUTO_RENEWAL_TERM",
|
||||
"NUM_AVAILABLE_SIGNATORY_LINE_COUNT",
|
||||
"NUM_SIGNED_SIGNATORY_LINE_COUNT",
|
||||
"AARETE_DERIVED_SIGNATORY_COMPLETE_IND",
|
||||
"EXHIBIT_TITLE",
|
||||
"EXHIBIT_PAGE",
|
||||
"REIMB_PROV_TIN",
|
||||
"REIMB_PROV_NPI",
|
||||
"REIMB_PROV_NAME",
|
||||
"REIMB_EFFECTIVE_DT",
|
||||
"REIMB_TERMINATION_DT",
|
||||
"CLAIM_TYPE_CD",
|
||||
"AARETE_DERIVED_CLAIM_TYPE_CD",
|
||||
"PRODUCT",
|
||||
"AARETE_DERIVED_PRODUCT",
|
||||
"LOB",
|
||||
"AARETE_DERIVED_LOB",
|
||||
"PROGRAM",
|
||||
"AARETE_DERIVED_PROGRAM",
|
||||
"NETWORK",
|
||||
"AARETE_DERIVED_NETWORK",
|
||||
"LOB_PROGRAM_RELATIONSHIP",
|
||||
"LOB_PRODUCT_RELATIONSHIP",
|
||||
"SERVICE_AREA",
|
||||
"AARETE_DERIVED_SERVICE_AREA",
|
||||
"PROV_TYPE",
|
||||
"AARETE_DERIVED_PROV_TYPE",
|
||||
"PROV_TAXONOMY_CD",
|
||||
"PROV_TAXONOMY_CD_DESC",
|
||||
"PROV_SPECIALTY_CD",
|
||||
"PROV_SPECIALTY_CD_DESC",
|
||||
"PLACE_OF_SERVICE_CD",
|
||||
"PLACE_OF_SERVICE_CD_DESC",
|
||||
"BILL_TYPE_CD",
|
||||
"BILL_TYPE_CD_DESC",
|
||||
"PATIENT_AGE_MIN",
|
||||
"PATIENT_AGE_MAX",
|
||||
"REIMB_TERM",
|
||||
"REIMB_ID",
|
||||
"REIMB_LESSER_OF_ID",
|
||||
"CARVEOUT_IND",
|
||||
"CARVEOUT_CD",
|
||||
"LESSER_OF_IND",
|
||||
"GREATER_OF_IND",
|
||||
"AARETE_DERIVED_REIMB_METHOD",
|
||||
"UNIT_OF_MEASURE",
|
||||
"REIMB_PCT_RATE",
|
||||
"REIMB_FEE_RATE",
|
||||
"REIMB_CONVERSION_FACTOR",
|
||||
"TRIGGER_CAP_THRESHOLD_AMT",
|
||||
"TRIGGER_BASE_THRESHOLD",
|
||||
"DEFAULT_IND",
|
||||
"ADDITION_DESC",
|
||||
"ADDITION_MAX_PCT_RATE_INC",
|
||||
"ADDITION_MAX_FEE_RATE_INC",
|
||||
"ADDITION_RATE_CHANGE_TIMELINE",
|
||||
"AARETE_DERIVED_ADDITION_RATE_CHANGE_TIMELINE",
|
||||
"FEE_SCHEDULE",
|
||||
"AARETE_DERIVED_FEE_SCHEDULE",
|
||||
"FEE_SCHEDULE_VERSION",
|
||||
"AARETE_DERIVED_FEE_SCHEDULE_VERSION",
|
||||
"SERVICE_TERM",
|
||||
"CPT4_PROC_CD",
|
||||
"CPT4_PROC_CD_DESC",
|
||||
"CPT4_PROC_MOD",
|
||||
"CPT4_PROC_MOD_DESC",
|
||||
"REVENUE_CD",
|
||||
"REVENUE_CD_DESC",
|
||||
"DIAG_CD",
|
||||
"DIAG_CD_DESC",
|
||||
"NDC_CD",
|
||||
"NDC_CD_DESC",
|
||||
"HIPPS_CD",
|
||||
"HIPPS_CD_DESC",
|
||||
"RUG_CD",
|
||||
"RUG_CD_DESC",
|
||||
"CLAIM_ADMIT_TYPE_CD",
|
||||
"AUTH_ADMIT_TYPE_DESC",
|
||||
"CLAIM_STATUS_CD",
|
||||
"CLAIM_STATUS_CD_DESC",
|
||||
"CODE_METHODOLOGY",
|
||||
"GROUPER_TYPE",
|
||||
"GROUPER_CD",
|
||||
"GROUPER_CD_DESC",
|
||||
"GROUPER_PCT_RATE",
|
||||
"GROUPER_BASE_RATE",
|
||||
"GROUPER_VERSION",
|
||||
"AARETE_DERIVED_GROUPER_VERSION",
|
||||
"GROUPER_ALTERNATIVE_LEVEL_OF_CARE",
|
||||
"GROUPER_SEVERITY_IND",
|
||||
"GROUPER_SEVERITY",
|
||||
"GROUPER_RISK_OF_MORTALITY_SUBCLASS",
|
||||
"GROUPER_TRANSFER_IND",
|
||||
"GROUPER_READMISSIONS_IND",
|
||||
"GROUPER_HAC_IND",
|
||||
"OUTLIER_TERM",
|
||||
"OUTLIER_FIRST_DOLLAR_IND",
|
||||
"RANGE_NBR_DAYS",
|
||||
"OUTLIER_FIXED_LOSS_NBR_DAYS_THRESHOLD",
|
||||
"OUTLIER_FIXED_LOSS_THRESHOLD",
|
||||
"OUTLIER_MAXIMUM",
|
||||
"OUTLIER_MAXIMUM_FREQUENCY",
|
||||
"OUTLIER_PCT_RATE",
|
||||
"OUTLIER_EXCLUSION_CD",
|
||||
"OUTLIER_EXCLUSION_CD_DESC",
|
||||
"FACILITY_ADJUSTMENT_TERM",
|
||||
"DSH_IND",
|
||||
"IME_IND",
|
||||
"NTAP_IND",
|
||||
"UC_IND",
|
||||
"GME_IND",
|
||||
"RATE_ESCALATOR_IND",
|
||||
"RATE_ESCALATOR_TERM",
|
||||
"RATE_ESCALATOR_MAX_RATE_INC_PCT",
|
||||
"RATE_ESCALATOR_RATE_CHANGE_TIMELINE",
|
||||
"STOP_LOSS_TERM",
|
||||
"STOP_LOSS_FIRST_DOLLAR_IND",
|
||||
"STOP_LOSS_RANGE_NBR_DAYS",
|
||||
"STOP_LOSS_FIXED_LOSS_THRESHOLD",
|
||||
"STOP_LOSS_MAXIMUM",
|
||||
"STOP_LOSS_MAXIMUM_FREQUENCY",
|
||||
"STOP_LOSS_DAILY_MAX_RATE",
|
||||
"STOP_LOSS_PCT_RATE_ON_EXCESS_CHARGES",
|
||||
"STOP_LOSS_EXCLUSION_CD",
|
||||
"STOP_LOSS_EXCLUSION_CD_DESC",
|
||||
"DISCOUNT_PERCENT_RATE",
|
||||
"DISCOUNT_RATE_CHANGE_INTERVAL",
|
||||
"DISCOUNT_START_DT",
|
||||
"DISCOUNT_END_DT",
|
||||
"PREMIUM_PERCENT_RATE",
|
||||
"PREMIUM_RATE_CHANGE_INTERVAL",
|
||||
"PREMIUM_START_DT",
|
||||
"PREMIUM_END_DT",
|
||||
"SEQUESTRATION_TERM",
|
||||
"SEQUESTRATION_START_DT",
|
||||
"SEQUESTRATION_END_DT",
|
||||
]
|
||||
# This is the ONE AND ONLY source of investment column names, order, and data types.
|
||||
# Do not reference anything else for column definitions.
|
||||
"""
|
||||
Investment column configuration.
|
||||
|
||||
This module defines:
|
||||
- FIELD_FORMAT_MAPPING: Dictionary mapping field names to their expected output format types
|
||||
|
||||
Format types:
|
||||
- 'str': Single string value
|
||||
- 'list[str]': List of strings
|
||||
- 'list[dict[str,str]]': List of dictionaries with string keys and values
|
||||
- 'dict[str,str]': Dictionary with string keys and values
|
||||
|
||||
This file should be edited directly. When adding new fields:
|
||||
1. Add the field name and format type to FIELD_FORMAT_MAPPING
|
||||
2. Python dicts preserve insertion order (since Python 3.7), so FIELD_FORMAT_MAPPING
|
||||
will maintain the order defined
|
||||
"""
|
||||
|
||||
# Field format mapping
|
||||
# Python dicts preserve insertion order (since Python 3.7)
|
||||
FIELD_FORMAT_MAPPING = {
|
||||
"AARETE_DERIVED_SID": "str",
|
||||
"CONTRACT_CLASS": "str",
|
||||
"AARETE_DERIVED_CONTRACT_CLASS": "str",
|
||||
"FILE_NAME": "str",
|
||||
"CONTRACT_TITLE": "str",
|
||||
"CONTRACT_AMENDMENT_NUM": "str",
|
||||
"FILENAME_AMENDMENT_NUM": "str", # Default: not in original mapping
|
||||
"AARETE_DERIVED_AMENDMENT_NUM": "str",
|
||||
"CLIENT_NAME": "str",
|
||||
"PAYER_NAME": "str",
|
||||
"PAYER_STATE": "list[str]",
|
||||
"PROVIDER_STATE": "list[str]",
|
||||
"FILENAME_TIN": "list[str]",
|
||||
"PROV_GROUP_TIN": "list[str]",
|
||||
"PROV_GROUP_NPI": "list[str]",
|
||||
"PROV_GROUP_NAME_FULL": "list[str]",
|
||||
"PROV_OTHER_TIN": "list[str]",
|
||||
"PROV_OTHER_NPI": "list[str]",
|
||||
"PROV_OTHER_NAME_FULL": "list[str]",
|
||||
"PROV_INFO_JSON": "list[dict[str,str]]",
|
||||
"EFFECTIVE_DT": "str",
|
||||
"AARETE_DERIVED_EFFECTIVE_DT": "str",
|
||||
"TERMINATION_DT": "str",
|
||||
"AARETE_DERIVED_TERMINATION_DT": "str",
|
||||
"AUTO_RENEWAL_IND": "str",
|
||||
"AUTO_RENEWAL_TERM": "str",
|
||||
"NUM_AVAILABLE_SIGNATORY_LINE_COUNT": "str",
|
||||
"NUM_SIGNED_SIGNATORY_LINE_COUNT": "str",
|
||||
"AARETE_DERIVED_SIGNATORY_COMPLETE_IND": "str",
|
||||
"EXHIBIT_TITLE": "str",
|
||||
"EXHIBIT_PAGE": "str",
|
||||
"REIMB_PROV_TIN": "list[str]",
|
||||
"REIMB_PROV_NPI": "list[str]",
|
||||
"REIMB_PROV_NAME": "list[str]",
|
||||
"REIMB_EFFECTIVE_DT": "str",
|
||||
"REIMB_TERMINATION_DT": "str",
|
||||
"CLAIM_TYPE_CD": "str",
|
||||
"AARETE_DERIVED_CLAIM_TYPE_CD": "str",
|
||||
"PRODUCT": "list[str]",
|
||||
"AARETE_DERIVED_PRODUCT": "list[str]",
|
||||
"LOB": "list[str]",
|
||||
"AARETE_DERIVED_LOB": "list[str]",
|
||||
"PROGRAM": "list[str]",
|
||||
"AARETE_DERIVED_PROGRAM": "list[str]",
|
||||
"NETWORK": "list[str]",
|
||||
"AARETE_DERIVED_NETWORK": "list[str]",
|
||||
"LOB_PROGRAM_RELATIONSHIP": "str",
|
||||
"LOB_PRODUCT_RELATIONSHIP": "str",
|
||||
"SERVICE_AREA": "str", # Default: not in original mapping
|
||||
"AARETE_DERIVED_SERVICE_AREA": "str", # Default: not in original mapping
|
||||
"PROV_TYPE": "str", # Default: not in original mapping
|
||||
"AARETE_DERIVED_PROV_TYPE": "str", # Default: not in original mapping
|
||||
"PROV_TAXONOMY_CD": "list[str]",
|
||||
"PROV_TAXONOMY_CD_DESC": "list[str]",
|
||||
"PROV_SPECIALTY_CD": "list[str]",
|
||||
"PROV_SPECIALTY_CD_DESC": "list[str]",
|
||||
"PLACE_OF_SERVICE_CD": "list[str]",
|
||||
"PLACE_OF_SERVICE_CD_DESC": "list[str]",
|
||||
"BILL_TYPE_CD": "list[str]",
|
||||
"BILL_TYPE_CD_DESC": "list[str]",
|
||||
"PATIENT_AGE_MIN": "str",
|
||||
"PATIENT_AGE_MAX": "str",
|
||||
"REIMB_TERM": "str",
|
||||
"REIMB_ID": "str",
|
||||
"REIMB_LESSER_OF_ID": "str",
|
||||
"CARVEOUT_IND": "str",
|
||||
"CARVEOUT_CD": "str",
|
||||
"LESSER_OF_IND": "str",
|
||||
"GREATER_OF_IND": "str",
|
||||
"AARETE_DERIVED_REIMB_METHOD": "str",
|
||||
"UNIT_OF_MEASURE": "str",
|
||||
"REIMB_PCT_RATE": "str",
|
||||
"REIMB_FEE_RATE": "str",
|
||||
"REIMB_CONVERSION_FACTOR": "str",
|
||||
"TRIGGER_CAP_THRESHOLD_AMT": "str",
|
||||
"TRIGGER_BASE_THRESHOLD": "str",
|
||||
"DEFAULT_IND": "str",
|
||||
"ADDITION_DESC": "str",
|
||||
"ADDITION_MAX_PCT_RATE_INC": "str",
|
||||
"ADDITION_MAX_FEE_RATE_INC": "str",
|
||||
"ADDITION_RATE_CHANGE_TIMELINE": "str",
|
||||
"AARETE_DERIVED_ADDITION_RATE_CHANGE_TIMELINE": "str",
|
||||
"FEE_SCHEDULE": "str",
|
||||
"AARETE_DERIVED_FEE_SCHEDULE": "str",
|
||||
"FEE_SCHEDULE_VERSION": "str",
|
||||
"AARETE_DERIVED_FEE_SCHEDULE_VERSION": "str",
|
||||
"SERVICE_TERM": "str",
|
||||
"CPT4_PROC_CD": "list[str]",
|
||||
"CPT4_PROC_CD_DESC": "list[str]",
|
||||
"CPT4_PROC_MOD": "list[str]",
|
||||
"CPT4_PROC_MOD_DESC": "list[str]",
|
||||
"REVENUE_CD": "list[str]",
|
||||
"REVENUE_CD_DESC": "list[str]",
|
||||
"DIAG_CD": "list[str]",
|
||||
"DIAG_CD_DESC": "list[str]",
|
||||
"NDC_CD": "list[str]",
|
||||
"NDC_CD_DESC": "list[str]",
|
||||
"HIPPS_CD": "list[str]",
|
||||
"HIPPS_CD_DESC": "list[str]",
|
||||
"RUG_CD": "list[str]",
|
||||
"RUG_CD_DESC": "list[str]",
|
||||
"CLAIM_ADMIT_TYPE_CD": "list[str]",
|
||||
"AUTH_ADMIT_TYPE_DESC": "list[str]",
|
||||
"CLAIM_STATUS_CD": "list[str]",
|
||||
"CLAIM_STATUS_CD_DESC": "list[str]",
|
||||
"CODE_METHODOLOGY": "str",
|
||||
"GROUPER_TYPE": "str",
|
||||
"GROUPER_CD": "list[str]",
|
||||
"GROUPER_CD_DESC": "list[str]",
|
||||
"GROUPER_PCT_RATE": "str",
|
||||
"GROUPER_BASE_RATE": "str",
|
||||
"GROUPER_VERSION": "str",
|
||||
"AARETE_DERIVED_GROUPER_VERSION": "str",
|
||||
"GROUPER_ALTERNATIVE_LEVEL_OF_CARE": "str",
|
||||
"GROUPER_SEVERITY_IND": "str",
|
||||
"GROUPER_SEVERITY": "str",
|
||||
"GROUPER_RISK_OF_MORTALITY_SUBCLASS": "str",
|
||||
"GROUPER_TRANSFER_IND": "str",
|
||||
"GROUPER_READMISSIONS_IND": "str",
|
||||
"GROUPER_HAC_IND": "str",
|
||||
"OUTLIER_TERM": "str",
|
||||
"OUTLIER_FIRST_DOLLAR_IND": "str",
|
||||
"RANGE_NBR_DAYS": "str",
|
||||
"OUTLIER_FIXED_LOSS_NBR_DAYS_THRESHOLD": "str",
|
||||
"OUTLIER_FIXED_LOSS_THRESHOLD": "str",
|
||||
"OUTLIER_MAXIMUM": "str",
|
||||
"OUTLIER_MAXIMUM_FREQUENCY": "str",
|
||||
"OUTLIER_PCT_RATE": "str",
|
||||
"OUTLIER_EXCLUSION_CD": "list[str]",
|
||||
"OUTLIER_EXCLUSION_CD_DESC": "list[str]",
|
||||
"FACILITY_ADJUSTMENT_TERM": "str",
|
||||
"DSH_IND": "str",
|
||||
"IME_IND": "str",
|
||||
"NTAP_IND": "str",
|
||||
"UC_IND": "str",
|
||||
"GME_IND": "str",
|
||||
"RATE_ESCALATOR_IND": "str",
|
||||
"RATE_ESCALATOR_TERM": "str",
|
||||
"RATE_ESCALATOR_MAX_RATE_INC_PCT": "str",
|
||||
"RATE_ESCALATOR_RATE_CHANGE_TIMELINE": "str",
|
||||
"STOP_LOSS_TERM": "str",
|
||||
"STOP_LOSS_FIRST_DOLLAR_IND": "str",
|
||||
"STOP_LOSS_RANGE_NBR_DAYS": "str",
|
||||
"STOP_LOSS_FIXED_LOSS_THRESHOLD": "str",
|
||||
"STOP_LOSS_MAXIMUM": "str",
|
||||
"STOP_LOSS_MAXIMUM_FREQUENCY": "str",
|
||||
"STOP_LOSS_DAILY_MAX_RATE": "str",
|
||||
"STOP_LOSS_PCT_RATE_ON_EXCESS_CHARGES": "str",
|
||||
"STOP_LOSS_EXCLUSION_CD": "list[str]",
|
||||
"STOP_LOSS_EXCLUSION_CD_DESC": "list[str]",
|
||||
"DISCOUNT_PERCENT_RATE": "str",
|
||||
"DISCOUNT_RATE_CHANGE_INTERVAL": "str",
|
||||
"DISCOUNT_START_DT": "str",
|
||||
"DISCOUNT_END_DT": "str",
|
||||
"PREMIUM_PERCENT_RATE": "str",
|
||||
"PREMIUM_RATE_CHANGE_INTERVAL": "str",
|
||||
"PREMIUM_START_DT": "str",
|
||||
"PREMIUM_END_DT": "str",
|
||||
"SEQUESTRATION_TERM": "str",
|
||||
"SEQUESTRATION_START_DT": "str",
|
||||
"SEQUESTRATION_END_DT": "str",
|
||||
}
|
||||
|
||||
@@ -260,14 +260,6 @@ def run_one_to_one_prompts(
|
||||
one_to_one_results, contract_text
|
||||
)
|
||||
|
||||
################## Normalize before crosswalk ##################
|
||||
# Normalize before get_crosswalk_fields (which expects/returns a list)
|
||||
# get_crosswalk_fields and fill_na_mapping are mechanical steps (no LLM),
|
||||
# so they won't introduce single-element lists that need normalization
|
||||
one_to_one_results = string_utils.normalize_one_to_one_answers_dict(
|
||||
one_to_one_results
|
||||
)
|
||||
|
||||
################## Crosswalk Fields ##################
|
||||
one_to_one_results = aarete_derived.get_crosswalk_fields(
|
||||
[one_to_one_results], constants
|
||||
|
||||
@@ -28,15 +28,6 @@ def prompt_exhibit_level(
|
||||
|
||||
llm_answer_final = _parser(llm_answer_raw)
|
||||
|
||||
# Normalize CLAIM_TYPE_CD if present: convert single-element lists to strings
|
||||
# This field should be a single value even though it's a 1:N field
|
||||
if "CLAIM_TYPE_CD" in llm_answer_final:
|
||||
llm_answer_final["CLAIM_TYPE_CD"] = (
|
||||
string_utils.normalize_one_to_one_field_value(
|
||||
"CLAIM_TYPE_CD", llm_answer_final["CLAIM_TYPE_CD"]
|
||||
)
|
||||
)
|
||||
|
||||
return llm_answer_final
|
||||
|
||||
|
||||
@@ -445,11 +436,6 @@ def prompt_full_context(
|
||||
instruction=prompt_templates.ONE_TO_ONE_MULTI_FIELD_INSTRUCTION(),
|
||||
)
|
||||
full_context_answers_dict = _parser(claude_answer_raw)
|
||||
# Normalize 1:1 fields: convert single-element lists to strings
|
||||
# Preserves legitimate multi-value lists (e.g., LOB, PROGRAM when passed to 1:1)
|
||||
full_context_answers_dict = string_utils.normalize_one_to_one_answers_dict(
|
||||
full_context_answers_dict
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing high accuracy fields: {str(e)}")
|
||||
full_context_answers_dict = {}
|
||||
|
||||
@@ -262,14 +262,6 @@ def run_one_to_one_prompts(
|
||||
one_to_one_results, contract_text
|
||||
)
|
||||
|
||||
################## Normalize before crosswalk ##################
|
||||
# Normalize before get_crosswalk_fields (which expects/returns a list)
|
||||
# get_crosswalk_fields and fill_na_mapping are mechanical steps (no LLM),
|
||||
# so they won't introduce single-element lists that need normalization
|
||||
one_to_one_results = string_utils.normalize_one_to_one_answers_dict(
|
||||
one_to_one_results
|
||||
)
|
||||
|
||||
################## Crosswalk Fields ##################
|
||||
one_to_one_results = aarete_derived.get_crosswalk_fields(
|
||||
[one_to_one_results], constants
|
||||
|
||||
@@ -34,15 +34,6 @@ def prompt_exhibit_level(
|
||||
|
||||
llm_answer_final = _parser(llm_answer_raw)
|
||||
|
||||
# Normalize CLAIM_TYPE_CD if present: convert single-element lists to strings
|
||||
# This field should be a single value even though it's a 1:N field
|
||||
if "CLAIM_TYPE_CD" in llm_answer_final:
|
||||
llm_answer_final["CLAIM_TYPE_CD"] = (
|
||||
string_utils.normalize_one_to_one_field_value(
|
||||
"CLAIM_TYPE_CD", llm_answer_final["CLAIM_TYPE_CD"]
|
||||
)
|
||||
)
|
||||
|
||||
return llm_answer_final
|
||||
|
||||
|
||||
@@ -470,12 +461,6 @@ def prompt_full_context(
|
||||
if not isinstance(full_context_answers_dict, dict):
|
||||
full_context_answers_dict = extract_and_parse(claude_answer_raw)
|
||||
|
||||
# Normalize 1:1 fields: convert single-element lists to strings
|
||||
# Preserves legitimate multi-value lists (e.g., LOB, PROGRAM when passed to 1:1)
|
||||
full_context_answers_dict = string_utils.normalize_one_to_one_answers_dict(
|
||||
full_context_answers_dict
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing high accuracy fields: {str(e)}")
|
||||
full_context_answers_dict = {}
|
||||
|
||||
@@ -242,15 +242,10 @@ def run_one_to_one_prompts(
|
||||
one_to_one_results, contract_text
|
||||
)
|
||||
|
||||
################## Normalize before crosswalk ##################
|
||||
# Normalize before get_crosswalk_fields (which expects/returns a list)
|
||||
# get_crosswalk_fields and fill_na_mapping are mechanical steps (no LLM),
|
||||
# so they won't introduce single-element lists that need normalization
|
||||
one_to_one_results = string_utils.normalize_one_to_one_answers_dict(
|
||||
one_to_one_results
|
||||
)
|
||||
|
||||
################## Crosswalk Fields ##################
|
||||
# All field format normalization is handled at prompt_calls level via field-aware parsers
|
||||
# Derived fields (AARETE_DERIVED_*, NUM_*_SIGNATORY_*) are created as strings directly
|
||||
# No additional normalization needed here
|
||||
one_to_one_results = aarete_derived.get_crosswalk_fields(
|
||||
[one_to_one_results], constants
|
||||
)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import src.config as config
|
||||
import src.prompts.prompt_templates as prompt_templates
|
||||
from src.constants.constants import Constants
|
||||
from src.constants.investment_columns import FIELD_FORMAT_MAPPING
|
||||
from src.prompts.fieldset import Field, FieldSet
|
||||
from src.utils import llm_utils, string_utils
|
||||
from src.utils.formatting_utils import normalize_field_value
|
||||
from src.pipelines.shared.extraction import tin_npi_funcs
|
||||
import json
|
||||
|
||||
@@ -19,8 +22,13 @@ def prompt_exhibit_level(
|
||||
logging.debug(exhibit_level_fields.print_prompt_dict(constants))
|
||||
if not exhibit_level_fields.contains_fields():
|
||||
return {}
|
||||
# Extract field names from FieldSet for format normalization
|
||||
field_names = [field.field_name for field in exhibit_level_fields.fields]
|
||||
|
||||
prompt, _parser = prompt_templates.EXHIBIT_LEVEL(
|
||||
exhibit_text, exhibit_level_fields.print_prompt_dict(constants)
|
||||
exhibit_text,
|
||||
exhibit_level_fields.print_prompt_dict(constants),
|
||||
field_names=field_names,
|
||||
)
|
||||
llm_answer_raw = llm_utils.invoke_claude(
|
||||
prompt,
|
||||
@@ -34,7 +42,6 @@ def prompt_exhibit_level(
|
||||
logging.debug(f"LLM raw output for {filename}: {llm_answer_raw}")
|
||||
|
||||
llm_answer_final = _parser(llm_answer_raw)
|
||||
|
||||
return llm_answer_final
|
||||
|
||||
|
||||
@@ -93,6 +100,7 @@ def prompt_dynamic_primary(
|
||||
)
|
||||
logging.debug(f"Claude answer for {filename}; {field}: {llm_answer_raw}")
|
||||
llm_answer_final = _parser(llm_answer_raw)
|
||||
# Normalization is already done in the JSON parser
|
||||
|
||||
return llm_answer_final
|
||||
|
||||
@@ -124,11 +132,12 @@ def prompt_reimbursement_primary(
|
||||
|
||||
try:
|
||||
llm_answer_final = _parser(llm_answer_raw)
|
||||
# Normalization is already done in the JSON parser
|
||||
return llm_answer_final
|
||||
except ValueError as e:
|
||||
logging.error(f"Error parsing LLM response: {e}")
|
||||
logging.error(f"Raw LLM output: {llm_answer_raw}")
|
||||
raise
|
||||
return llm_answer_final
|
||||
|
||||
|
||||
def prompt_methodology_breakout(
|
||||
@@ -156,6 +165,17 @@ def prompt_methodology_breakout(
|
||||
logging.debug(f"LLM Response for {filename}: {llm_response}")
|
||||
try:
|
||||
methodology_breakout_answers = _parser(llm_response)
|
||||
# Normalize output - methodology_breakout returns list[dict] with methodology fields
|
||||
# Use FieldSet to get field names from single source of truth
|
||||
methodology_breakout_fields = FieldSet(
|
||||
config.FIELD_JSON_PATH, field_type="methodology_breakout"
|
||||
)
|
||||
methodology_field_names = methodology_breakout_fields.list_fields()
|
||||
# Handle both list and single dict cases
|
||||
if isinstance(methodology_breakout_answers, dict):
|
||||
methodology_breakout_answers = [methodology_breakout_answers]
|
||||
# Normalization is already done in the JSON parser
|
||||
return methodology_breakout_answers
|
||||
except ValueError as e:
|
||||
logging.error(f"Error parsing LLM response: {e}")
|
||||
methodology_breakout_answers = []
|
||||
@@ -189,6 +209,7 @@ def prompt_fee_schedule_breakout(
|
||||
logging.debug(f"LLM Response for {filename}: {llm_answer_raw}")
|
||||
try:
|
||||
fs_breakout_dict = _parser(llm_answer_raw)
|
||||
# Normalization is already done in the JSON parser
|
||||
except ValueError as e:
|
||||
fs_breakout_dict = {}
|
||||
return fs_breakout_dict
|
||||
@@ -219,6 +240,7 @@ def prompt_grouper_breakout(
|
||||
logging.debug(f"LLM Response for {filename}: {llm_answer_raw}")
|
||||
try:
|
||||
grouper_breakout_dict = _parser(llm_answer_raw)
|
||||
# Normalization is already done in the JSON parser
|
||||
except:
|
||||
grouper_breakout_dict = {}
|
||||
|
||||
@@ -248,7 +270,14 @@ def prompt_carveout_check(
|
||||
)
|
||||
|
||||
carveout_answer = _parser(llm_answer_raw)
|
||||
return carveout_answer[0]
|
||||
# Parser normalizes to str format for CARVEOUT_CD, but returns as list
|
||||
# Extract first element and ensure it's a string
|
||||
if isinstance(carveout_answer, list) and len(carveout_answer) > 0:
|
||||
result = carveout_answer[0]
|
||||
return str(result) if result is not None else "N/A"
|
||||
elif isinstance(carveout_answer, str):
|
||||
return carveout_answer
|
||||
return "N/A" # Default fallback
|
||||
|
||||
|
||||
def prompt_special_case_breakout(breakout_template, term_answer, filename):
|
||||
@@ -286,9 +315,12 @@ def prompt_special_case_breakout(breakout_template, term_answer, filename):
|
||||
|
||||
def prompt_lob_relationship(
|
||||
answer_dict: dict, field: str, exhibit_text: str, filename: str
|
||||
):
|
||||
) -> str:
|
||||
"""
|
||||
Helper function to prompt LLM for LOB relationship based on the field and exhibit text.
|
||||
|
||||
Returns:
|
||||
str: Normalized relationship string ("Inclusive" or "Exclusive")
|
||||
"""
|
||||
programs = answer_dict.get(field)
|
||||
dynamic_primary_values = (
|
||||
@@ -306,13 +338,15 @@ def prompt_lob_relationship(
|
||||
instruction=prompt_templates.LOB_RELATIONSHIP_INSTRUCTION(),
|
||||
)
|
||||
llm_answer_final = _parser(llm_answer_raw)
|
||||
# LOB_RELATIONSHIP returns a list (e.g., ["Inclusive"]), but we need a string
|
||||
if isinstance(llm_answer_final, list) and len(llm_answer_final) > 0:
|
||||
return str(llm_answer_final[0])
|
||||
elif isinstance(llm_answer_final, str):
|
||||
return llm_answer_final
|
||||
else:
|
||||
return "Exclusive" # Default fallback
|
||||
|
||||
# Parser normalizes to str format per field mapping, but may return list or str
|
||||
# Normalize to ensure we always return a string
|
||||
field_name = "LOB_PROGRAM_RELATIONSHIP" # Both fields use same format (str)
|
||||
format_type = FIELD_FORMAT_MAPPING.get(field_name, "str")
|
||||
|
||||
# Normalize the result to str format (handles both list and str inputs)
|
||||
normalized = normalize_field_value(field_name, llm_answer_final, format_type)
|
||||
return normalized if normalized else "Exclusive"
|
||||
|
||||
|
||||
def prompt_special_case_assignment(
|
||||
@@ -353,9 +387,10 @@ def prompt_special_case_assignment(
|
||||
)
|
||||
index_answer = _parser(llm_answer_raw)
|
||||
try:
|
||||
# Parser returns a list, extract first element
|
||||
# Parser returns a list of indices, normalize and extract first element
|
||||
# This is a special case - we need the index as an integer
|
||||
if isinstance(index_answer, list) and len(index_answer) > 0:
|
||||
index_str = index_answer[0]
|
||||
index_str = str(index_answer[0]) # Convert to string first
|
||||
if not string_utils.is_empty(index_str):
|
||||
return special_case_dicts[int(index_str)]
|
||||
return {}
|
||||
@@ -369,9 +404,11 @@ def prompt_full_context(
|
||||
constants: Constants,
|
||||
filename: str,
|
||||
):
|
||||
|
||||
prompt_questions = full_context_fields.print_prompt_dict(constants)
|
||||
|
||||
# Extract field names for field-aware normalization
|
||||
field_names = full_context_fields.list_fields()
|
||||
|
||||
full_context_prompt, _parser = prompt_templates.ONE_TO_ONE_MULTI_FIELD_TEMPLATE(
|
||||
context=contract_text[
|
||||
0 : min(
|
||||
@@ -380,6 +417,7 @@ def prompt_full_context(
|
||||
)
|
||||
],
|
||||
questions=prompt_questions,
|
||||
field_names=field_names,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -393,11 +431,8 @@ def prompt_full_context(
|
||||
)
|
||||
|
||||
full_context_answers_dict = _parser(claude_answer_raw)
|
||||
# Normalize 1:1 fields: convert single-element lists to strings
|
||||
# Preserves legitimate multi-value lists (e.g., LOB, PROGRAM when passed to 1:1)
|
||||
full_context_answers_dict = string_utils.normalize_one_to_one_answers_dict(
|
||||
full_context_answers_dict
|
||||
)
|
||||
# Field-aware normalization is already done in the JSON parser
|
||||
# No additional normalization needed for fields in FIELD_FORMAT_MAPPING
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing high accuracy fields: {str(e)}")
|
||||
full_context_answers_dict = {}
|
||||
@@ -457,7 +492,11 @@ def prompt_dynamic(text: str, field_prompts, filename):
|
||||
Returns:
|
||||
dict: A dictionary containing field names as keys and extracted answers as values.
|
||||
"""
|
||||
prompt, _parser = prompt_templates.EXHIBIT_LEVEL(text, field_prompts)
|
||||
# Extract field names from field_prompts dict for format-aware normalization
|
||||
field_names = list(field_prompts.keys()) if isinstance(field_prompts, dict) else []
|
||||
prompt, _parser = prompt_templates.EXHIBIT_LEVEL(
|
||||
text, field_prompts, field_names=field_names
|
||||
)
|
||||
logging.debug(f"Dynamic prompt for {filename}: {prompt}")
|
||||
llm_answer_raw = llm_utils.invoke_claude(
|
||||
prompt,
|
||||
@@ -469,6 +508,7 @@ def prompt_dynamic(text: str, field_prompts, filename):
|
||||
|
||||
logging.debug(f"Dynamic answer for {filename}: {llm_answer_raw}")
|
||||
llm_answer_final = _parser(llm_answer_raw)
|
||||
# Normalization is already done in the JSON parser
|
||||
return llm_answer_final
|
||||
|
||||
|
||||
@@ -496,6 +536,15 @@ def prompt_exhibit_linkage(page_header, previous_header, filename):
|
||||
usage_label="EXHIBIT_LINKAGE",
|
||||
)
|
||||
exhibit_is_different = _parser(claude_answer_raw)
|
||||
# Normalize output - EXHIBIT_LINKAGE returns a list, normalize it
|
||||
# No specific field name, so normalize based on content
|
||||
if isinstance(exhibit_is_different, list):
|
||||
# Extract first element if list (legacy behavior)
|
||||
exhibit_is_different = (
|
||||
exhibit_is_different[0]
|
||||
if len(exhibit_is_different) > 0
|
||||
else exhibit_is_different
|
||||
)
|
||||
|
||||
return exhibit_is_different
|
||||
|
||||
@@ -525,7 +574,10 @@ def prompt_exhibit_header(page_content, filename):
|
||||
usage_label="EXHIBIT_HEADER",
|
||||
)
|
||||
llm_answer_extracted = _parser(llm_answer_raw)
|
||||
return llm_answer_extracted[0]
|
||||
# Parser returns a list, extract first element
|
||||
if isinstance(llm_answer_extracted, list) and len(llm_answer_extracted) > 0:
|
||||
return llm_answer_extracted[0]
|
||||
return llm_answer_extracted
|
||||
|
||||
|
||||
def prompt_date_fix(date: str) -> str:
|
||||
@@ -548,7 +600,12 @@ def prompt_date_fix(date: str) -> str:
|
||||
instruction=prompt_templates.DATE_FIX_INSTRUCTION(),
|
||||
usage_label="DATE_FIX",
|
||||
)
|
||||
return _parser(response)
|
||||
parsed = _parser(response)
|
||||
# Normalize output - date_fix returns a list, but we need a string
|
||||
# No specific field name, so extract first element if list
|
||||
if isinstance(parsed, list) and len(parsed) > 0:
|
||||
return parsed[0]
|
||||
return parsed
|
||||
|
||||
|
||||
def prompt_derived_term_date(
|
||||
@@ -577,7 +634,11 @@ def prompt_derived_term_date(
|
||||
instruction=prompt_templates.DERIVED_TERM_DATE_INSTRUCTION(),
|
||||
usage_label="DERIVED_TERM_DATE",
|
||||
)
|
||||
return _parser(response)
|
||||
parsed = _parser(response)
|
||||
# Parser returns a list, extract first element
|
||||
if isinstance(parsed, list) and len(parsed) > 0:
|
||||
return parsed[0]
|
||||
return parsed
|
||||
|
||||
|
||||
def prompt_dynamic_assignment(
|
||||
@@ -624,6 +685,7 @@ def prompt_dynamic_assignment(
|
||||
|
||||
try:
|
||||
llm_answer_final = _parser(llm_answer_raw)
|
||||
# Normalization is already done in the JSON parser
|
||||
return llm_answer_final
|
||||
except:
|
||||
logging.error(
|
||||
@@ -704,14 +766,12 @@ def prompt_lesser_of_distribution(
|
||||
|
||||
logging.debug(
|
||||
f"Applied lesser-of to '{service_term}' on page {page_num}: "
|
||||
f"{reimb_term} → {llm_answer_final[:60]}..."
|
||||
)
|
||||
|
||||
return (
|
||||
llm_answer_final[0]
|
||||
if isinstance(llm_answer_final, list)
|
||||
else llm_answer_final
|
||||
f"{reimb_term} → {llm_answer_final[:60] if isinstance(llm_answer_final, str) else str(llm_answer_final)[:60]}..."
|
||||
)
|
||||
# Parser returns a list, extract first element
|
||||
if isinstance(llm_answer_final, list) and len(llm_answer_final) > 0:
|
||||
return llm_answer_final[0]
|
||||
return llm_answer_final
|
||||
|
||||
|
||||
def prompt_lesser_of_check(
|
||||
@@ -753,6 +813,7 @@ def prompt_lesser_of_check(
|
||||
|
||||
try:
|
||||
llm_answer_final = _parser(llm_answer_raw)
|
||||
# Normalization is already done in the JSON parser
|
||||
logging.debug(f"LESSER_OF_CHECK extracted from pipes: {llm_answer_final}")
|
||||
|
||||
# Validate required fields
|
||||
@@ -830,7 +891,12 @@ def prompt_exhibit_title_match(
|
||||
|
||||
try:
|
||||
llm_answer_final = _parser(llm_answer_raw)
|
||||
return llm_answer_final[0].strip().upper() # Return "YES" or "NO" as string
|
||||
# Normalize output - EXHIBIT_TITLE_MATCH returns a list, but we need a string
|
||||
# No specific field name, so extract first element and normalize
|
||||
if isinstance(llm_answer_final, list) and len(llm_answer_final) > 0:
|
||||
result = str(llm_answer_final[0]).strip().upper()
|
||||
return result
|
||||
return "NO" # Default to "NO" string
|
||||
except:
|
||||
return "NO" # Default to "NO" string
|
||||
|
||||
@@ -868,7 +934,12 @@ def provider_name_match_check(
|
||||
|
||||
# Extract last character (should be Y or N)
|
||||
llm_answer_final = _parser(llm_answer_raw)
|
||||
response_char = llm_answer_final[0]
|
||||
# Normalize output - CHECK_PROVIDER_NAME_MATCH returns a list
|
||||
# No specific field name, so extract first element
|
||||
if isinstance(llm_answer_final, list) and len(llm_answer_final) > 0:
|
||||
response_char = str(llm_answer_final[0])
|
||||
else:
|
||||
response_char = str(llm_answer_final) if llm_answer_final else "N"
|
||||
|
||||
if response_char == "Y":
|
||||
logging.debug(
|
||||
@@ -893,7 +964,14 @@ def provider_name_match_check(
|
||||
return False
|
||||
|
||||
|
||||
def prompt_split_reimb_dates(date_range: str, filename: str) -> dict:
|
||||
def prompt_split_reimb_dates(date_range: str, filename: str) -> dict[str, str]:
|
||||
"""
|
||||
Split a date range string into effective and termination dates.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with 'start_date' and 'end_date' keys, both normalized
|
||||
to str format matching REIMB_EFFECTIVE_DT and REIMB_TERMINATION_DT.
|
||||
"""
|
||||
prompt, _parser = prompt_templates.SPLIT_REIMB_DATES(date_range)
|
||||
|
||||
# Invoke LLM to split the date range
|
||||
@@ -909,7 +987,23 @@ def prompt_split_reimb_dates(date_range: str, filename: str) -> dict:
|
||||
|
||||
# Extract the effective and termination dates from the LLM output
|
||||
llm_answer_final = _parser(llm_answer_raw)
|
||||
return llm_answer_final
|
||||
|
||||
# Normalize date values to str format matching field format mapping
|
||||
# The parser returns dict with 'start_date' and 'end_date' keys
|
||||
# We normalize these values to match REIMB_EFFECTIVE_DT and REIMB_TERMINATION_DT formats
|
||||
normalized_dict = {}
|
||||
if isinstance(llm_answer_final, dict):
|
||||
format_type = FIELD_FORMAT_MAPPING.get("REIMB_EFFECTIVE_DT", "str")
|
||||
if "start_date" in llm_answer_final:
|
||||
normalized_dict["start_date"] = normalize_field_value(
|
||||
"REIMB_EFFECTIVE_DT", llm_answer_final["start_date"], format_type
|
||||
)
|
||||
if "end_date" in llm_answer_final:
|
||||
normalized_dict["end_date"] = normalize_field_value(
|
||||
"REIMB_TERMINATION_DT", llm_answer_final["end_date"], format_type
|
||||
)
|
||||
|
||||
return normalized_dict if normalized_dict else llm_answer_final
|
||||
|
||||
|
||||
def prompt_provider_info(
|
||||
@@ -957,11 +1051,17 @@ def prompt_provider_info(
|
||||
logging.debug(f"Parsed provider info on page {page_num}: {providers}")
|
||||
# Ensure we have a list of providers (not a dict or other type)
|
||||
if not isinstance(providers, list):
|
||||
# If not a list or dict, create a default list with error info
|
||||
logging.warning(
|
||||
f"Warning: Unexpected format in provider info response: {type(providers)}"
|
||||
)
|
||||
providers = [{"TIN": "N/A", "NPI": "N/A", "NAME": "PARSING_ERROR"}]
|
||||
# If single provider returned as dict, wrap in list
|
||||
if isinstance(providers, dict):
|
||||
providers = [providers]
|
||||
else:
|
||||
# If not a list or dict, create a default list with error info
|
||||
logging.warning(
|
||||
f"Warning: Unexpected format in provider info response: {type(providers)}"
|
||||
)
|
||||
providers = [{"TIN": "N/A", "NPI": "N/A", "NAME": "PARSING_ERROR"}]
|
||||
|
||||
# Normalization is already done in the JSON parser
|
||||
|
||||
# VALIDATION LAYER - Cross-check with regex findings
|
||||
page_text = text_dict[page_num]
|
||||
|
||||
@@ -680,6 +680,7 @@ def split_reimb_dates(one_to_n_results: list, filename: str) -> list:
|
||||
date_range, filename
|
||||
)
|
||||
# Update the specific record with parsed dates
|
||||
# Values are already normalized to str format in prompt_calls.py
|
||||
if llm_answer_final and isinstance(llm_answer_final, dict):
|
||||
if "start_date" in llm_answer_final:
|
||||
record["REIMB_EFFECTIVE_DT"] = llm_answer_final["start_date"]
|
||||
@@ -782,34 +783,9 @@ def one_to_n_cleaning(
|
||||
all_exhibit_rows = split_reimb_dates(all_exhibit_rows, filename)
|
||||
|
||||
################################ Normalize Single-Value Fields to Strings ################################
|
||||
# REIMB_TERM, SERVICE_TERM, and LOB_PROGRAM_RELATIONSHIP should always be strings (not lists)
|
||||
for answer_dict in all_exhibit_rows:
|
||||
# LOB_PROGRAM_RELATIONSHIP should be a string (not a list)
|
||||
if "LOB_PROGRAM_RELATIONSHIP" in answer_dict:
|
||||
lob_program_rel = answer_dict["LOB_PROGRAM_RELATIONSHIP"]
|
||||
if isinstance(lob_program_rel, list):
|
||||
# If list, take first element (shouldn't happen, but defensive)
|
||||
answer_dict["LOB_PROGRAM_RELATIONSHIP"] = (
|
||||
str(lob_program_rel[0]) if lob_program_rel else "Exclusive"
|
||||
)
|
||||
elif lob_program_rel is not None:
|
||||
answer_dict["LOB_PROGRAM_RELATIONSHIP"] = str(lob_program_rel)
|
||||
else:
|
||||
answer_dict["LOB_PROGRAM_RELATIONSHIP"] = "Exclusive"
|
||||
|
||||
# LOB_PRODUCT_RELATIONSHIP should be a string (not a list)
|
||||
if "LOB_PRODUCT_RELATIONSHIP" in answer_dict:
|
||||
lob_product_rel = answer_dict["LOB_PRODUCT_RELATIONSHIP"]
|
||||
if isinstance(lob_product_rel, list):
|
||||
# If list, take first element (shouldn't happen, but defensive)
|
||||
answer_dict["LOB_PRODUCT_RELATIONSHIP"] = (
|
||||
str(lob_product_rel[0]) if lob_product_rel else "Exclusive"
|
||||
)
|
||||
elif lob_product_rel is not None:
|
||||
answer_dict["LOB_PRODUCT_RELATIONSHIP"] = str(lob_product_rel)
|
||||
else:
|
||||
answer_dict["LOB_PRODUCT_RELATIONSHIP"] = "Exclusive"
|
||||
|
||||
# REIMB_TERM, SERVICE_TERM should always be strings (not lists)
|
||||
# LOB_PROGRAM_RELATIONSHIP and LOB_PRODUCT_RELATIONSHIP are already normalized to strings
|
||||
# in prompt_calls.prompt_lob_relationship(), so no additional normalization needed here
|
||||
return all_exhibit_rows
|
||||
|
||||
|
||||
|
||||
@@ -197,29 +197,22 @@ def run_full_context_fields(
|
||||
)
|
||||
|
||||
# Normalize PAYER_STATE to two-letter abbreviation
|
||||
if "PAYER_STATE" in full_context_answers_dict:
|
||||
full_context_answers_dict["PAYER_STATE"] = (
|
||||
string_utils.normalize_state_to_abbreviation(
|
||||
full_context_answers_dict["PAYER_STATE"]
|
||||
)
|
||||
)
|
||||
# normalize_state_field correctly handles list[str] format from field-aware parser
|
||||
full_context_answers_dict = string_utils.normalize_state_field(
|
||||
full_context_answers_dict, field_name="PAYER_STATE"
|
||||
)
|
||||
|
||||
# normalize PROVIDER_STATE to two-letter abbreviation
|
||||
if "PROVIDER_STATE" in full_context_answers_dict:
|
||||
full_context_answers_dict = string_utils.normalize_state_field(
|
||||
full_context_answers_dict, field_name="PROVIDER_STATE"
|
||||
)
|
||||
# Normalize PROVIDER_STATE to two-letter abbreviation
|
||||
# normalize_state_field correctly handles list[str] format from field-aware parser
|
||||
full_context_answers_dict = string_utils.normalize_state_field(
|
||||
full_context_answers_dict, field_name="PROVIDER_STATE"
|
||||
)
|
||||
# Run Special Case Breakout on any breakout terms
|
||||
full_context_answers_dict = one_to_one_breakout(full_context_answers_dict, filename)
|
||||
|
||||
# Normalize breakout fields: prompt_special_case_breakout uses _json_dict_parser
|
||||
# which may return lists, so normalize any newly added fields
|
||||
full_context_answers_dict = string_utils.normalize_one_to_one_answers_dict(
|
||||
full_context_answers_dict
|
||||
)
|
||||
# Field-aware normalization is already done in prompt_calls.prompt_full_context()
|
||||
# and prompt_special_case_breakout() uses field-aware parsers
|
||||
# No additional normalization needed for fields in FIELD_FORMAT_MAPPING
|
||||
|
||||
# Handle separate one-to-one fields that require individual processing
|
||||
separate_fields = one_to_one_fields.filter(field_type="full_context_separate")
|
||||
@@ -309,14 +302,10 @@ def one_to_one_breakout(
|
||||
breakout_answer_dict = prompt_calls.prompt_special_case_breakout(
|
||||
breakout_template, term_answer, filename
|
||||
)
|
||||
# Normalize breakout fields for 1:1: _json_dict_parser may return lists for values
|
||||
# Convert single-element lists to strings, preserve multi-value lists
|
||||
# Note: This normalization is only for 1:1 fields; 1:N breakouts are handled separately
|
||||
# Field-aware normalization is already done in prompt_special_case_breakout()
|
||||
# which uses field-aware parsers based on the breakout template's field_names
|
||||
if isinstance(breakout_answer_dict, dict):
|
||||
breakout_answer_dict = string_utils.normalize_one_to_one_answers_dict(
|
||||
breakout_answer_dict
|
||||
)
|
||||
full_context_answers_dict.update(breakout_answer_dict)
|
||||
full_context_answers_dict.update(breakout_answer_dict)
|
||||
return full_context_answers_dict
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ import logging
|
||||
import src.config as config
|
||||
import src.utils.string_utils as string_utils
|
||||
from src.constants.constants import Constants
|
||||
from src.constants.investment_columns import FIELD_FORMAT_MAPPING
|
||||
from src.crosswalk.crosswalk_builder import CrosswalkBuilder
|
||||
from src.prompts.fieldset import FieldSet
|
||||
from src.utils.crosswalk_utils import apply_crosswalk
|
||||
from src.utils.formatting_utils import normalize_field_value
|
||||
|
||||
|
||||
def fill_na_from_field(results_dict, to_field, from_field, crosswalk_path):
|
||||
@@ -172,16 +174,9 @@ def get_crosswalk_fields(answer_dicts: list, constants: Constants):
|
||||
"""
|
||||
Apply crosswalk mappings to derive target fields from source fields.
|
||||
|
||||
Fields that should be single values (strings) even when crosswalk creates lists:
|
||||
- AARETE_DERIVED_CLAIM_TYPE_CD: Claim type is a single classification value
|
||||
- Other AARETE_DERIVED_* fields that represent single classifications (not multi-value like LOB)
|
||||
Normalizes output based on FIELD_FORMAT_MAPPING to ensure correct format
|
||||
(str vs list[str]) for each target field.
|
||||
"""
|
||||
# Fields that should always be single values (strings), not lists
|
||||
# These are typically classification/derived fields that represent a single value
|
||||
SINGLE_VALUE_FIELDS = {
|
||||
"AARETE_DERIVED_CLAIM_TYPE_CD",
|
||||
# Add other single-value derived fields here as needed
|
||||
}
|
||||
|
||||
crosswalk_fields = FieldSet(file_path=config.FIELD_JSON_PATH, crosswalk=True)
|
||||
for to_field in crosswalk_fields.fields:
|
||||
@@ -233,14 +228,12 @@ def get_crosswalk_fields(answer_dicts: list, constants: Constants):
|
||||
individual_from_field_value
|
||||
)
|
||||
|
||||
# Assign the result: convert to string if it's a single-value field with one element
|
||||
if (
|
||||
to_field_name in SINGLE_VALUE_FIELDS
|
||||
and len(to_field_answer_list) == 1
|
||||
):
|
||||
answer_dict[to_field_name] = to_field_answer_list[0]
|
||||
else:
|
||||
answer_dict[to_field_name] = to_field_answer_list
|
||||
# Normalize the result based on FIELD_FORMAT_MAPPING
|
||||
format_type = FIELD_FORMAT_MAPPING.get(to_field_name, "list[str]")
|
||||
normalized_value = normalize_field_value(
|
||||
to_field_name, to_field_answer_list, format_type
|
||||
)
|
||||
answer_dict[to_field_name] = normalized_value
|
||||
|
||||
# DO NOT update from_field_name - preserve its original format
|
||||
# The crosswalk should only create/update the target field (to_field_name),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import src.config as config
|
||||
from src.constants.constants import Constants
|
||||
from src.constants.investment_columns import COLUMN_ORDER
|
||||
from src.constants.investment_columns import FIELD_FORMAT_MAPPING
|
||||
from src.pipelines.shared.postprocessing import postprocessing_funcs
|
||||
|
||||
|
||||
@@ -88,6 +88,6 @@ def postprocess(df, constants: Constants):
|
||||
df = postprocessing_funcs.attach_sid_column(df)
|
||||
|
||||
# Standardize output column order - this should ALWAYS be the final postprocessing step
|
||||
df = postprocessing_funcs.reorder_columns(df, COLUMN_ORDER)
|
||||
df = postprocessing_funcs.reorder_columns(df, FIELD_FORMAT_MAPPING)
|
||||
|
||||
return df
|
||||
|
||||
@@ -311,7 +311,9 @@ def generate_reimb_ids(df: pd.DataFrame) -> pd.DataFrame:
|
||||
return df_temp
|
||||
|
||||
|
||||
def reorder_columns(df: pd.DataFrame, column_order: list[str]) -> pd.DataFrame:
|
||||
def reorder_columns(
|
||||
df: pd.DataFrame, field_format_mapping: dict[str, str]
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Reorders the columns of the DataFrame based on the given column order.
|
||||
Steps:
|
||||
@@ -326,6 +328,9 @@ def reorder_columns(df: pd.DataFrame, column_order: list[str]) -> pd.DataFrame:
|
||||
Returns:
|
||||
pd.DataFrame: The reordered DataFrame.
|
||||
"""
|
||||
# Get a list of column names, preserving order
|
||||
column_order = list(field_format_mapping.keys())
|
||||
|
||||
# Create a copy to avoid fragmentation
|
||||
df_copy = df.copy()
|
||||
|
||||
|
||||
@@ -284,10 +284,6 @@ def run_hybrid_smart_chunked_fields(
|
||||
answers_dict, field_name="PAYER_STATE"
|
||||
)
|
||||
|
||||
# Normalize all 1:1 fields: convert single-element lists to strings
|
||||
# Preserves legitimate multi-value lists (e.g., LOB, PROGRAM when passed to 1:1)
|
||||
answers_dict = string_utils.normalize_one_to_one_answers_dict(answers_dict)
|
||||
|
||||
return answers_dict
|
||||
|
||||
except Exception as e:
|
||||
@@ -372,7 +368,7 @@ def prompt_hsc_single_field(
|
||||
constants
|
||||
) # Returns dict of {field_name : prompt}
|
||||
prompt, _parser = prompt_templates.ONE_TO_ONE_SINGLE_FIELD_TEMPLATE(
|
||||
context, llm_prompt
|
||||
context, llm_prompt, field_name=field.field_name
|
||||
)
|
||||
|
||||
logging.debug(f"Field: {field.field_name}")
|
||||
@@ -388,12 +384,19 @@ def prompt_hsc_single_field(
|
||||
)
|
||||
try:
|
||||
llm_answer_final = _parser(llm_answer_raw) # returns dict
|
||||
|
||||
field_value = llm_answer_final.get(field.field_name)
|
||||
if not isinstance(field_value, list):
|
||||
field_value = [field_value]
|
||||
|
||||
if field_value[0] == "N/A":
|
||||
# Check for N/A - handle both string and list formats
|
||||
field_value_str = (
|
||||
field_value
|
||||
if isinstance(field_value, str)
|
||||
else (
|
||||
field_value[0]
|
||||
if isinstance(field_value, list) and field_value
|
||||
else "N/A"
|
||||
)
|
||||
)
|
||||
if field_value_str == "N/A":
|
||||
field.field_type = "full_context"
|
||||
if field.field_name == "PAYER_NAME":
|
||||
field.prompt = (
|
||||
|
||||
+191
-50
@@ -32,15 +32,43 @@ JSON_LIST_OF_DICTS_FORMAT_INSTRUCTIONS = """Return your final answer as a valid
|
||||
|
||||
# Parser helper functions for prompt templates
|
||||
def _json_dict_parser(raw_output: str) -> dict:
|
||||
"""Parse JSON dictionary from LLM output."""
|
||||
"""Parse JSON dictionary from LLM output (without field normalization).
|
||||
|
||||
For field-aware parsing, use _create_json_dict_parser() instead.
|
||||
"""
|
||||
return json_utils.parse_json_dict(raw_output)
|
||||
|
||||
|
||||
def _json_list_parser(raw_output: str) -> list:
|
||||
"""Parse JSON list from LLM output."""
|
||||
"""Parse JSON list from LLM output (without field normalization).
|
||||
|
||||
For field-aware parsing, use _create_json_list_parser() instead.
|
||||
"""
|
||||
return json_utils.parse_json_list(raw_output)
|
||||
|
||||
|
||||
def _create_json_dict_parser(field_names: list[str] | None = None):
|
||||
"""Create a JSON dict parser with field names bound for normalization."""
|
||||
|
||||
def parser(raw_output: str) -> dict:
|
||||
return json_utils.parse_json_dict(raw_output, field_names=field_names)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _create_json_list_parser(
|
||||
field_name: str | None = None, expected_format: str | None = None
|
||||
):
|
||||
"""Create a JSON list parser with field name bound for normalization."""
|
||||
|
||||
def parser(raw_output: str) -> list:
|
||||
return json_utils.parse_json_list(
|
||||
raw_output, field_name=field_name, expected_format=expected_format
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
# Module-level cached Constants instance for instruction functions
|
||||
# This is lazily initialized on first use and reused across all instruction calls
|
||||
_cached_constants = None
|
||||
@@ -235,10 +263,17 @@ Briefly explain your answer, then put the final answer in the properly-formatted
|
||||
{JSON_DICT_WITH_LISTS_FORMAT_INSTRUCTIONS}"""
|
||||
|
||||
|
||||
def EXHIBIT_LEVEL(context, fields) -> Tuple[str, Callable[[str], dict]]:
|
||||
def EXHIBIT_LEVEL(
|
||||
context, fields, field_names: list[str] | None = None
|
||||
) -> Tuple[str, Callable[[str], dict]]:
|
||||
"""Returns ONLY dynamic content for exhibit level extraction.
|
||||
Call EXHIBIT_LEVEL_INSTRUCTION() separately for the cached instruction.
|
||||
|
||||
Args:
|
||||
context: The exhibit text to analyze.
|
||||
fields: Formatted string of field definitions.
|
||||
field_names: Optional list of field names for format-aware normalization.
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
@@ -250,7 +285,8 @@ Here are the attributes to be included in the dictionary, and instructions on ho
|
||||
Here is the text to analyze:
|
||||
{context.replace('"', "'")}"""
|
||||
|
||||
return (prompt, _json_dict_parser)
|
||||
parser = _create_json_dict_parser(field_names) if field_names else _json_dict_parser
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def DYNAMIC_PRIMARY_INSTRUCTION() -> str:
|
||||
@@ -277,6 +313,11 @@ def DYNAMIC_PRIMARY(
|
||||
"""Returns ONLY dynamic content for text-based dynamic primary extraction.
|
||||
Call DYNAMIC_PRIMARY_TEXT_INSTRUCTION() separately for the cached instruction.
|
||||
|
||||
Args:
|
||||
context: The exhibit text to analyze.
|
||||
field_name: The field name being extracted (used for format-aware normalization).
|
||||
field_prompt: The prompt/description for the field.
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON list output.
|
||||
"""
|
||||
@@ -287,7 +328,9 @@ def DYNAMIC_PRIMARY(
|
||||
Here is the text to analyze:
|
||||
{context.replace('"', "'")}"""
|
||||
|
||||
return (prompt, _json_list_parser)
|
||||
# Create parser with field_name bound for format-aware normalization
|
||||
parser = _create_json_list_parser(field_name=field_name)
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def REIMB_DATES_ASSIGNMENT_INSTRUCTION() -> str:
|
||||
@@ -370,6 +413,13 @@ def REIMB_DATES_ASSIGNMENT(
|
||||
"""Returns ONLY dynamic content for REIMB_DATES assignment.
|
||||
Call REIMB_DATES_ASSIGNMENT_INSTRUCTION() separately for the cached instruction.
|
||||
|
||||
Args:
|
||||
service_term: Service description
|
||||
reimb_term: Reimbursement term text
|
||||
field_prompt: Prompt/definition for REIMB_DATES field
|
||||
exhibit_text_simplified: Simplified exhibit text
|
||||
page_num: Page number
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
@@ -387,7 +437,9 @@ This is the term you need to find the date range for. It appears on page {page_n
|
||||
Service Term: "{service_term}"
|
||||
Reimbursement Term: "{reimb_term}" """
|
||||
|
||||
return (prompt, _json_dict_parser)
|
||||
# Create parser with REIMB_DATES field name bound for format-aware normalization
|
||||
parser = _create_json_dict_parser(field_names=["REIMB_DATES"])
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def DYNAMIC_ASSIGNMENT_INSTRUCTION() -> str:
|
||||
@@ -460,6 +512,14 @@ def DYNAMIC_ASSIGNMENT(
|
||||
Returns prompt for dynamic assignment extraction.
|
||||
Call DYNAMIC_ASSIGNMENT_INSTRUCTION() separately for the cached instruction.
|
||||
|
||||
Args:
|
||||
service_term: Service description
|
||||
reimb_term: Reimbursement term text
|
||||
field_name: Name of the field being assigned (used for format-aware normalization)
|
||||
field_prompt: Prompt/definition for the field
|
||||
exhibit_text_simplified: Simplified exhibit text
|
||||
page_num: Page number
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
@@ -478,7 +538,10 @@ Reimbursement Term: "{reimb_term}"
|
||||
Here is the definition and valid values. Use this information to help you find the right answer.
|
||||
{field_name} : {field_prompt}"""
|
||||
|
||||
return (prompt, _json_dict_parser)
|
||||
# Create parser with field_name bound for format-aware normalization
|
||||
# The dict will contain {field_name: value}, so we need to normalize that field
|
||||
parser = _create_json_dict_parser(field_names=[field_name])
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def REIMBURSEMENT_PRIMARY(context) -> Tuple[str, Callable[[str], list]]:
|
||||
@@ -497,7 +560,11 @@ def REIMBURSEMENT_PRIMARY(context) -> Tuple[str, Callable[[str], list]]:
|
||||
Briefly talk through your reasoning. Then return a properly formatted JSON list of dictionaries with all extracted SERVICE_TERM AND REIMB_TERM values.
|
||||
"""
|
||||
|
||||
return (prompt, _json_list_parser)
|
||||
# Use helper function to create parser for list[dict[str,str]]
|
||||
parser = _create_json_list_parser(
|
||||
field_name=None, expected_format="list[dict[str,str]]"
|
||||
)
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def REIMBURSEMENT_PRIMARY_INSTRUCTION() -> str:
|
||||
@@ -827,7 +894,11 @@ Reimbursement Term: {reimb_term}
|
||||
|
||||
Explain your reasoning as you work through the context. After your explanation, include the heading "FINAL REIMBURSEMENT METHODOLOGY:" followed by a properly formatted JSON list with your final output."""
|
||||
|
||||
return (prompt, _json_list_parser)
|
||||
# Use helper function to create parser for list[dict[str,str]]
|
||||
parser = _create_json_list_parser(
|
||||
field_name=None, expected_format="list[dict[str,str]]"
|
||||
)
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def METHODOLOGY_BREAKOUT_INSTRUCTION() -> str:
|
||||
@@ -907,7 +978,13 @@ def FEE_SCHEDULE_BREAKOUT(
|
||||
Analyze and respond to the following text, specifically for {fee_schedule} Fee Schedule:
|
||||
Methodology: {methodology.replace('"', "'")}"""
|
||||
|
||||
return (prompt, _json_dict_parser)
|
||||
# Extract field names for format-aware normalization
|
||||
fee_schedule_fields = FieldSet(
|
||||
config.FIELD_JSON_PATH, field_type="fee_schedule_breakout"
|
||||
)
|
||||
field_names = [field.field_name for field in fee_schedule_fields.fields]
|
||||
parser = _create_json_dict_parser(field_names=field_names)
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def GROUPER_BREAKOUT_INSTRUCTION() -> str:
|
||||
@@ -949,13 +1026,25 @@ Here are the service and reimbursement terms to analyze:
|
||||
SERVICE: {service}
|
||||
REIMBURSEMENT TERM: {term}"""
|
||||
|
||||
return (prompt, _json_dict_parser)
|
||||
# Extract field names for format-aware normalization
|
||||
grouper_fields = FieldSet(config.FIELD_JSON_PATH, field_type="grouper_breakout")
|
||||
field_names = [field.field_name for field in grouper_fields.fields]
|
||||
parser = _create_json_dict_parser(field_names=field_names)
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def SPECIAL_CASE_BREAKOUT(term, questions) -> Tuple[str, Callable[[str], dict]]:
|
||||
def SPECIAL_CASE_BREAKOUT(
|
||||
term, questions, field_names: list[str] | None = None
|
||||
) -> Tuple[str, Callable[[str], dict]]:
|
||||
"""
|
||||
Generic prompt for special case breakout fields when no additional descriptions are needed.
|
||||
|
||||
Args:
|
||||
term: The term text to analyze
|
||||
questions: Formatted string of field definitions
|
||||
field_names: Optional list of field names for format-aware normalization.
|
||||
If None, will attempt to extract from questions string.
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
@@ -974,7 +1063,9 @@ Here is the term to analyze:
|
||||
Briefly explain your answer, then put your final answer in JSON dictionary format.
|
||||
"""
|
||||
|
||||
return (prompt, _json_dict_parser)
|
||||
# Create parser with field_names bound for format-aware normalization
|
||||
parser = _create_json_dict_parser(field_names=field_names)
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def RATE_ESCALATOR_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
@@ -982,16 +1073,17 @@ def RATE_ESCALATOR_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
Creates a prompt to extract rate escalator field information from a rate escalator statement.
|
||||
|
||||
Args:
|
||||
rate_escalator_statement: The text containing rate escalator information
|
||||
rate_escalator_questions: Dictionary or list of questions with instructions for each field
|
||||
term: The term text to analyze
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
questions = FieldSet(
|
||||
rate_escalator_fields = FieldSet(
|
||||
config.FIELD_JSON_PATH, field_type="rate_escalator_breakout"
|
||||
).print_prompt_dict()
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions)
|
||||
)
|
||||
questions = rate_escalator_fields.print_prompt_dict()
|
||||
field_names = [field.field_name for field in rate_escalator_fields.fields]
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions, field_names=field_names)
|
||||
|
||||
|
||||
def TRIGGER_CAP_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
@@ -1001,10 +1093,12 @@ def TRIGGER_CAP_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
questions = FieldSet(
|
||||
trigger_cap_fields = FieldSet(
|
||||
config.FIELD_JSON_PATH, field_type="trigger_cap_breakout"
|
||||
).print_prompt_dict()
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions)
|
||||
)
|
||||
questions = trigger_cap_fields.print_prompt_dict()
|
||||
field_names = [field.field_name for field in trigger_cap_fields.fields]
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions, field_names=field_names)
|
||||
|
||||
|
||||
def OUTLIER_BREAKOUT_INSTRUCTION() -> str:
|
||||
@@ -1046,20 +1140,30 @@ def OUTLIER_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
Here is the text to analyze and respond to:
|
||||
{term}"""
|
||||
|
||||
return (prompt, _json_dict_parser)
|
||||
# Extract field names for format-aware normalization
|
||||
outlier_fields = FieldSet(config.FIELD_JSON_PATH, field_type="outlier_breakout")
|
||||
field_names = [field.field_name for field in outlier_fields.fields]
|
||||
parser = _create_json_dict_parser(field_names=field_names)
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def FACILITY_ADJUSTMENT_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
"""
|
||||
Returns prompt and parser for facility adjustment breakout.
|
||||
|
||||
Args:
|
||||
term: The facility adjustment term text to analyze
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
questions = FieldSet(
|
||||
facility_adjustment_fields = FieldSet(
|
||||
config.FIELD_JSON_PATH, field_type="facility_adjustment_breakout"
|
||||
).print_prompt_dict()
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions)
|
||||
)
|
||||
questions = facility_adjustment_fields.print_prompt_dict()
|
||||
# Extract field names for format-aware normalization
|
||||
field_names = [field.field_name for field in facility_adjustment_fields.fields]
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions, field_names=field_names)
|
||||
|
||||
|
||||
def SEQUESTRATION_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
@@ -1069,10 +1173,12 @@ def SEQUESTRATION_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
questions = FieldSet(
|
||||
sequestration_fields = FieldSet(
|
||||
config.FIELD_JSON_PATH, field_type="sequestration_breakout"
|
||||
).print_prompt_dict()
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions)
|
||||
)
|
||||
questions = sequestration_fields.print_prompt_dict()
|
||||
field_names = [field.field_name for field in sequestration_fields.fields]
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions, field_names=field_names)
|
||||
|
||||
|
||||
def DISCOUNT_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
@@ -1082,10 +1188,10 @@ def DISCOUNT_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
questions = FieldSet(
|
||||
config.FIELD_JSON_PATH, field_type="discount_breakout"
|
||||
).print_prompt_dict()
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions)
|
||||
discount_fields = FieldSet(config.FIELD_JSON_PATH, field_type="discount_breakout")
|
||||
questions = discount_fields.print_prompt_dict()
|
||||
field_names = [field.field_name for field in discount_fields.fields]
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions, field_names=field_names)
|
||||
|
||||
|
||||
def PREMIUM_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
@@ -1095,10 +1201,10 @@ def PREMIUM_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
questions = FieldSet(
|
||||
config.FIELD_JSON_PATH, field_type="premium_breakout"
|
||||
).print_prompt_dict()
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions)
|
||||
premium_fields = FieldSet(config.FIELD_JSON_PATH, field_type="premium_breakout")
|
||||
questions = premium_fields.print_prompt_dict()
|
||||
field_names = [field.field_name for field in premium_fields.fields]
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions, field_names=field_names)
|
||||
|
||||
|
||||
def ADDITION_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
@@ -1108,10 +1214,10 @@ def ADDITION_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
questions = FieldSet(
|
||||
config.FIELD_JSON_PATH, field_type="addition_breakout"
|
||||
).print_prompt_dict()
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions)
|
||||
addition_fields = FieldSet(config.FIELD_JSON_PATH, field_type="addition_breakout")
|
||||
questions = addition_fields.print_prompt_dict()
|
||||
field_names = [field.field_name for field in addition_fields.fields]
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions, field_names=field_names)
|
||||
|
||||
|
||||
def STOP_LOSS_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
@@ -1121,10 +1227,10 @@ def STOP_LOSS_BREAKOUT(term) -> Tuple[str, Callable[[str], dict]]:
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
questions = FieldSet(
|
||||
config.FIELD_JSON_PATH, field_type="stop_loss_breakout"
|
||||
).print_prompt_dict()
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions)
|
||||
stop_loss_fields = FieldSet(config.FIELD_JSON_PATH, field_type="stop_loss_breakout")
|
||||
questions = stop_loss_fields.print_prompt_dict()
|
||||
field_names = [field.field_name for field in stop_loss_fields.fields]
|
||||
return SPECIAL_CASE_BREAKOUT(term, questions, field_names=field_names)
|
||||
|
||||
|
||||
#####################################################################################
|
||||
@@ -1397,11 +1503,16 @@ Here are the descriptions to choose from:
|
||||
|
||||
|
||||
def ONE_TO_ONE_SINGLE_FIELD_TEMPLATE(
|
||||
context: str, field_prompt: dict[str, str]
|
||||
context: str, field_prompt: dict[str, str], field_name: str | None = None
|
||||
) -> Tuple[str, Callable[[str], dict]]:
|
||||
"""
|
||||
Returns prompt and parser for one-to-one single field extraction.
|
||||
|
||||
Args:
|
||||
context: Contract text context
|
||||
field_prompt: Dictionary mapping field name to prompt question
|
||||
field_name: Optional field name for field-aware normalization
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
@@ -1415,7 +1526,12 @@ Answer the following question: {field_prompt}
|
||||
Briefly explain your answer, then put the final answer in a properly-formatted JSON dictionary, where the key is the field name and value is the answer. If there is no valid answer, return "N/A".
|
||||
"""
|
||||
|
||||
return (prompt, _json_dict_parser)
|
||||
# Use field-aware parser if field_name is provided
|
||||
if field_name:
|
||||
parser = _create_json_dict_parser(field_names=[field_name])
|
||||
else:
|
||||
parser = _json_dict_parser
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def ONE_TO_ONE_SINGLE_FIELD_INSTRUCTION() -> str:
|
||||
@@ -1596,11 +1712,16 @@ Note: PROVIDER NAME B is typically a single name but may contain multiple names.
|
||||
|
||||
|
||||
def ONE_TO_ONE_MULTI_FIELD_TEMPLATE(
|
||||
context, questions: dict[str, str]
|
||||
context, questions: dict[str, str], field_names: list[str] | None = None
|
||||
) -> Tuple[str, Callable[[str], dict]]:
|
||||
"""
|
||||
Returns prompt and parser for one-to-one multi-field extraction.
|
||||
|
||||
Args:
|
||||
context: Contract text context
|
||||
questions: Dictionary mapping field names to prompt questions
|
||||
field_names: Optional list of field names for field-aware normalization
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
"""
|
||||
@@ -1631,7 +1752,12 @@ The term length appears in two places but I'm selecting...
|
||||
"term_length": "36 months"
|
||||
}}
|
||||
"""
|
||||
return (prompt, _json_dict_parser)
|
||||
# Use field-aware parser if field_names provided
|
||||
if field_names:
|
||||
parser = _create_json_dict_parser(field_names=field_names)
|
||||
else:
|
||||
parser = _json_dict_parser
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def ONE_TO_ONE_MULTI_FIELD_INSTRUCTION() -> str:
|
||||
@@ -1988,13 +2114,16 @@ def CARVEOUT_CHECK(
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON list output.
|
||||
The parser normalizes the output for CARVEOUT_CD field format (str).
|
||||
"""
|
||||
prompt = f"""[REIMBURSEMENT TERM]
|
||||
Here is the Reimbursement Term to analyze:
|
||||
Service: {service_term}
|
||||
Reimbursement Term: {reimb_term.replace('"', "'")}"""
|
||||
|
||||
return (prompt, _json_list_parser)
|
||||
# Use field-aware parser to normalize CARVEOUT_CD (str format)
|
||||
parser = _create_json_list_parser(field_name="CARVEOUT_CD")
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def EFFECTIVE_DATE_FIX_PROMPT() -> Tuple[str, Callable[[str], dict]]:
|
||||
@@ -2066,10 +2195,15 @@ def SPLIT_REIMB_DATES(date_range: str) -> Tuple[str, Callable[[str], dict]]:
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON dict output.
|
||||
Note: The output dict contains 'start_date' and 'end_date' keys, which are
|
||||
normalized manually in split_reimb_dates() to match REIMB_EFFECTIVE_DT and
|
||||
REIMB_TERMINATION_DT field formats (both str).
|
||||
"""
|
||||
prompt = f"""[DATE RANGE TO EXTRACT]
|
||||
{date_range}"""
|
||||
|
||||
# Use standard parser - normalization happens manually in split_reimb_dates()
|
||||
# because the output keys are 'start_date'/'end_date', not the field names
|
||||
return (prompt, _json_dict_parser)
|
||||
|
||||
|
||||
@@ -2110,6 +2244,9 @@ def LOB_RELATIONSHIP(
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, parser_function) where parser expects JSON list output.
|
||||
The parser normalizes the output for LOB_PROGRAM_RELATIONSHIP or
|
||||
LOB_PRODUCT_RELATIONSHIP field format (str). Note: This parser is used for
|
||||
both fields, so normalization is based on str format.
|
||||
"""
|
||||
prompt = f"""[LOB AND PROGRAMS IDENTIFIED]
|
||||
It was previously identified that the Exhibit contains the following LOB and Programs:
|
||||
@@ -2119,7 +2256,11 @@ It was previously identified that the Exhibit contains the following LOB and Pro
|
||||
Here is the Exhibit to be analyzed:
|
||||
{exhibit_text.replace('"', "'")}"""
|
||||
|
||||
return (prompt, _json_list_parser)
|
||||
# Use field-aware parser to normalize relationship field (str format)
|
||||
# Note: This is used for both LOB_PROGRAM_RELATIONSHIP and LOB_PRODUCT_RELATIONSHIP,
|
||||
# both of which are str format, so we can use either field name
|
||||
parser = _create_json_list_parser(field_name="LOB_PROGRAM_RELATIONSHIP")
|
||||
return (prompt, parser)
|
||||
|
||||
|
||||
def DERIVED_TERM_DATE_INSTRUCTION() -> str:
|
||||
|
||||
@@ -9,7 +9,7 @@ Run it with `poetry run python -m src.testbed.add_reimb_id_to_testbed`
|
||||
|
||||
# Imports
|
||||
import pandas as pd
|
||||
from src.constants.investment_columns import COLUMN_ORDER
|
||||
from src.constants.investment_columns import FIELD_FORMAT_MAPPING
|
||||
from src.pipelines.shared.postprocessing.postprocessing_funcs import (
|
||||
generate_reimb_ids,
|
||||
reorder_columns,
|
||||
@@ -19,7 +19,7 @@ from src.pipelines.shared.postprocessing.postprocessing_funcs import (
|
||||
testbed = pd.read_excel("Doczy-Testbed.xlsx")
|
||||
|
||||
# Generate reimbursement IDs and reorder columns
|
||||
testbed_w_ids = reorder_columns(generate_reimb_ids(testbed), COLUMN_ORDER)
|
||||
testbed_w_ids = reorder_columns(generate_reimb_ids(testbed), FIELD_FORMAT_MAPPING)
|
||||
|
||||
# Save the updated testbed with reimbursement IDs
|
||||
testbed_w_ids.to_excel("Doczy-Testbed-with-Reimb-IDs.xlsx", index=False)
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections import Counter
|
||||
import pandas as pd
|
||||
import src.config as config
|
||||
import src.utils.string_utils as string_utils
|
||||
from src.constants.investment_columns import COLUMN_ORDER
|
||||
from src.constants.investment_columns import FIELD_FORMAT_MAPPING
|
||||
from rapidfuzz import fuzz
|
||||
|
||||
# Imports for the testbed postprocessing functions
|
||||
@@ -941,7 +941,7 @@ def testbed_postprocess(df):
|
||||
df = postprocessing_funcs.generate_reimb_ids(df)
|
||||
|
||||
# Standardize output column order - this should ALWAYS be the final postprocessing step
|
||||
df = postprocessing_funcs.reorder_columns(df, COLUMN_ORDER)
|
||||
df = postprocessing_funcs.reorder_columns(df, FIELD_FORMAT_MAPPING)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
+353
-182
@@ -2,14 +2,15 @@
|
||||
Unit tests for JSON utils module.
|
||||
|
||||
Tests the new JSON parsing functions that replace pipe-delimited output format.
|
||||
Also tests field-aware normalization functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from src.utils.json_utils import (
|
||||
parse_json_dict,
|
||||
parse_json_list,
|
||||
parse_json_dict_or_list,
|
||||
)
|
||||
from src.constants.investment_columns import FIELD_FORMAT_MAPPING
|
||||
|
||||
|
||||
class TestParseJsonDict:
|
||||
@@ -98,34 +99,129 @@ class TestParseJsonDict:
|
||||
result = parse_json_dict(output)
|
||||
assert result == {"field": "café", "emoji": "😀"}
|
||||
|
||||
def test_no_dict_raises_error(self):
|
||||
"""Test that ValueError is raised when no dict is found."""
|
||||
output = "This is just text without any JSON"
|
||||
with pytest.raises(ValueError, match="No valid JSON dictionary found"):
|
||||
parse_json_dict(output)
|
||||
|
||||
def test_only_list_raises_error(self):
|
||||
"""Test that ValueError is raised when only a list is found."""
|
||||
output = '["item1", "item2"]'
|
||||
with pytest.raises(ValueError, match="No valid JSON dictionary found"):
|
||||
parse_json_dict(output)
|
||||
|
||||
def test_malformed_json_raises_error(self):
|
||||
"""Test that ValueError is raised for malformed JSON."""
|
||||
output = '{"field": "value"' # Missing closing brace
|
||||
with pytest.raises(ValueError, match="No valid JSON dictionary found"):
|
||||
parse_json_dict(output)
|
||||
|
||||
def test_non_string_input_raises_error(self):
|
||||
"""Test that TypeError is raised for non-string input."""
|
||||
with pytest.raises(TypeError, match="Expected string input"):
|
||||
parse_json_dict(123)
|
||||
|
||||
def test_dict_with_list_values(self):
|
||||
"""Test parsing dict with list values."""
|
||||
output = '{"items": ["a", "b", "c"], "counts": [1, 2, 3]}'
|
||||
output = '{"items": ["a", "b", "c"], "numbers": [1, 2, 3]}'
|
||||
result = parse_json_dict(output)
|
||||
assert result == {"items": ["a", "b", "c"], "counts": [1, 2, 3]}
|
||||
# Lists are preserved, but if field is in mapping, will be normalized
|
||||
assert result["items"] == ["a", "b", "c"]
|
||||
assert result["numbers"] == [1, 2, 3]
|
||||
|
||||
def test_dict_with_field_names_normalization(self):
|
||||
"""Test parsing dict with field names for normalization."""
|
||||
# PAYER_STATE should be normalized to list[str]
|
||||
output = '{"PAYER_STATE": "IL", "CONTRACT_TITLE": "Test Contract"}'
|
||||
result = parse_json_dict(output, field_names=["PAYER_STATE", "CONTRACT_TITLE"])
|
||||
assert result["PAYER_STATE"] == ["IL"] # Normalized to list
|
||||
assert result["CONTRACT_TITLE"] == "Test Contract" # Stays as str
|
||||
|
||||
def test_dict_with_field_names_list_to_str(self):
|
||||
"""Test normalization of list to str for single-value fields."""
|
||||
# CONTRACT_TITLE should be normalized to str (single element list -> str)
|
||||
output = '{"CONTRACT_TITLE": ["Test Contract"]}'
|
||||
result = parse_json_dict(output, field_names=["CONTRACT_TITLE"])
|
||||
assert result["CONTRACT_TITLE"] == "Test Contract"
|
||||
|
||||
def test_dict_with_field_names_str_to_list(self):
|
||||
"""Test normalization of str to list for list fields."""
|
||||
# PAYER_STATE should be normalized to list[str]
|
||||
output = '{"PAYER_STATE": "IL", "PROVIDER_STATE": "CA"}'
|
||||
result = parse_json_dict(output, field_names=["PAYER_STATE", "PROVIDER_STATE"])
|
||||
assert result["PAYER_STATE"] == ["IL"]
|
||||
assert result["PROVIDER_STATE"] == ["CA"]
|
||||
|
||||
def test_dict_with_field_names_multiple_list_fields(self):
|
||||
"""Test normalization of multiple list fields."""
|
||||
output = '{"PAYER_STATE": "IL", "PROVIDER_STATE": "CA", "LOB": ["Medicare"]}'
|
||||
result = parse_json_dict(
|
||||
output, field_names=["PAYER_STATE", "PROVIDER_STATE", "LOB"]
|
||||
)
|
||||
assert result["PAYER_STATE"] == ["IL"]
|
||||
assert result["PROVIDER_STATE"] == ["CA"]
|
||||
assert result["LOB"] == ["Medicare"] # Already a list, stays as list
|
||||
|
||||
def test_dict_with_field_names_prov_info_json(self):
|
||||
"""Test normalization of PROV_INFO_JSON field."""
|
||||
# PROV_INFO_JSON should be list[dict[str,str]]
|
||||
output = (
|
||||
'{"PROV_INFO_JSON": [{"NAME": "Provider", "TIN": "123", "NPI": "456"}]}'
|
||||
)
|
||||
result = parse_json_dict(output, field_names=["PROV_INFO_JSON"])
|
||||
assert isinstance(result["PROV_INFO_JSON"], list)
|
||||
assert len(result["PROV_INFO_JSON"]) == 1
|
||||
assert result["PROV_INFO_JSON"][0] == {
|
||||
"NAME": "Provider",
|
||||
"TIN": "123",
|
||||
"NPI": "456",
|
||||
}
|
||||
|
||||
def test_dict_with_field_names_partial_normalization(self):
|
||||
"""Test that only specified fields are normalized when field_names provided."""
|
||||
output = '{"PAYER_STATE": "IL", "CONTRACT_TITLE": ["Test"], "UNKNOWN_FIELD": "value"}'
|
||||
result = parse_json_dict(output, field_names=["PAYER_STATE"])
|
||||
assert result["PAYER_STATE"] == [
|
||||
"IL"
|
||||
] # Normalized (in field_names and mapping)
|
||||
# CONTRACT_TITLE not in field_names, so not normalized even though it's in mapping
|
||||
assert result["CONTRACT_TITLE"] == ["Test"] # Preserved as-is
|
||||
# UNKNOWN_FIELD not in field_names and not in mapping, preserved as-is
|
||||
assert result["UNKNOWN_FIELD"] == "value" # Preserved as-is
|
||||
|
||||
def test_dict_without_field_names_still_normalizes(self):
|
||||
"""Test that dict normalization happens even without field_names."""
|
||||
# When field_names is None, all fields in the dict are normalized if they're in mapping
|
||||
output = '{"PAYER_STATE": "IL", "CONTRACT_TITLE": ["Test"]}'
|
||||
result = parse_json_dict(output) # No field_names provided
|
||||
# Should normalize based on field format mapping
|
||||
assert result["PAYER_STATE"] == ["IL"] # Normalized based on mapping
|
||||
assert result["CONTRACT_TITLE"] == "Test" # Normalized (list -> str)
|
||||
|
||||
def test_dict_with_unknown_field_no_normalization(self):
|
||||
"""Test that unknown fields are not normalized."""
|
||||
output = '{"UNKNOWN_FIELD": "value"}'
|
||||
result = parse_json_dict(output, field_names=["UNKNOWN_FIELD"])
|
||||
# Unknown field should remain as-is (no format mapping)
|
||||
assert result["UNKNOWN_FIELD"] == "value"
|
||||
|
||||
def test_dict_field_aware_verification(self):
|
||||
"""Comprehensive test to verify dict parser is truly field-aware."""
|
||||
# Test with multiple fields of different types
|
||||
output = '{"PAYER_STATE": "IL", "CONTRACT_TITLE": ["My Contract"], "LOB": "Medicare", "PROV_INFO_JSON": [{"NAME": "Provider"}]}'
|
||||
|
||||
# Test with field_names - only specified fields should be normalized
|
||||
result = parse_json_dict(output, field_names=["PAYER_STATE", "CONTRACT_TITLE"])
|
||||
|
||||
# Verify PAYER_STATE (list[str] in mapping) is normalized
|
||||
assert result["PAYER_STATE"] == ["IL"]
|
||||
assert isinstance(result["PAYER_STATE"], list)
|
||||
|
||||
# Verify CONTRACT_TITLE (str in mapping) is normalized from list to str
|
||||
assert result["CONTRACT_TITLE"] == "My Contract"
|
||||
assert isinstance(result["CONTRACT_TITLE"], str)
|
||||
|
||||
# Verify LOB (list[str] in mapping) is NOT normalized (not in field_names)
|
||||
assert result["LOB"] == "Medicare" # Preserved as string, not normalized
|
||||
assert isinstance(result["LOB"], str)
|
||||
|
||||
# Verify PROV_INFO_JSON (list[dict] in mapping) is NOT normalized (not in field_names)
|
||||
assert isinstance(result["PROV_INFO_JSON"], list)
|
||||
assert len(result["PROV_INFO_JSON"]) == 1
|
||||
|
||||
# Test without field_names - all fields in mapping should be normalized
|
||||
result_all = parse_json_dict(output)
|
||||
|
||||
# All fields in mapping should be normalized
|
||||
assert result_all["PAYER_STATE"] == ["IL"]
|
||||
assert isinstance(result_all["PAYER_STATE"], list)
|
||||
|
||||
assert result_all["CONTRACT_TITLE"] == "My Contract"
|
||||
assert isinstance(result_all["CONTRACT_TITLE"], str)
|
||||
|
||||
assert result_all["LOB"] == ["Medicare"] # Normalized to list
|
||||
assert isinstance(result_all["LOB"], list)
|
||||
|
||||
assert isinstance(result_all["PROV_INFO_JSON"], list)
|
||||
assert len(result_all["PROV_INFO_JSON"]) == 1
|
||||
|
||||
|
||||
class TestParseJsonList:
|
||||
@@ -145,51 +241,38 @@ class TestParseJsonList:
|
||||
|
||||
def test_list_with_explanation_after(self):
|
||||
"""Test parsing JSON list with explanatory text after it."""
|
||||
output = '["item1", "item2"] as you can see above'
|
||||
output = '["key", "value"] This is some explanation text.'
|
||||
result = parse_json_list(output)
|
||||
assert result == ["item1", "item2"]
|
||||
assert result == ["key", "value"]
|
||||
|
||||
def test_list_with_explanation_both_sides(self):
|
||||
"""Test parsing JSON list with text on both sides."""
|
||||
output = 'Results: ["YES"] based on analysis'
|
||||
output = 'Analysis: The answer is ["result", "success"] as shown above.'
|
||||
result = parse_json_list(output)
|
||||
assert result == ["YES"]
|
||||
|
||||
def test_list_of_numbers(self):
|
||||
"""Test parsing list of numbers."""
|
||||
output = "[1, 2, 3, 4, 5]"
|
||||
result = parse_json_list(output)
|
||||
assert result == [1, 2, 3, 4, 5]
|
||||
|
||||
def test_list_of_mixed_types(self):
|
||||
"""Test parsing list with mixed types."""
|
||||
output = '["string", 42, true, null]'
|
||||
result = parse_json_list(output)
|
||||
assert result == ["string", 42, True, None]
|
||||
assert result == ["result", "success"]
|
||||
|
||||
def test_nested_list(self):
|
||||
"""Test parsing nested JSON list."""
|
||||
output = '[["a", "b"], ["c", "d"]]'
|
||||
output = "[[1, 2], [3, 4], [5, 6]]"
|
||||
result = parse_json_list(output)
|
||||
assert result == [["a", "b"], ["c", "d"]]
|
||||
assert result == [[1, 2], [3, 4], [5, 6]]
|
||||
|
||||
def test_list_of_dicts(self):
|
||||
"""Test parsing list containing dictionaries."""
|
||||
output = '[{"field": "value1"}, {"field": "value2"}]'
|
||||
def test_list_with_special_characters(self):
|
||||
"""Test parsing list with special characters in strings."""
|
||||
output = '["value with \\"quotes\\"", "other value"]'
|
||||
result = parse_json_list(output)
|
||||
assert result == [{"field": "value1"}, {"field": "value2"}]
|
||||
assert result == ['value with "quotes"', "other value"]
|
||||
|
||||
def test_list_with_newlines(self):
|
||||
"""Test parsing list with newlines in the JSON."""
|
||||
output = """
|
||||
[
|
||||
"item1",
|
||||
"item2",
|
||||
"item3"
|
||||
"value1",
|
||||
"value2"
|
||||
]
|
||||
"""
|
||||
result = parse_json_list(output)
|
||||
assert result == ["item1", "item2", "item3"]
|
||||
assert result == ["value1", "value2"]
|
||||
|
||||
def test_multiple_lists_returns_last(self):
|
||||
"""Test that when multiple lists present, the last one is returned."""
|
||||
@@ -197,149 +280,237 @@ class TestParseJsonList:
|
||||
result = parse_json_list(output)
|
||||
assert result == ["second"]
|
||||
|
||||
def test_list_with_null_values(self):
|
||||
"""Test parsing list with null values."""
|
||||
output = '["value", null, "other"]'
|
||||
result = parse_json_list(output)
|
||||
assert result == ["value", None, "other"]
|
||||
|
||||
def test_list_with_boolean_values(self):
|
||||
"""Test parsing list with boolean values."""
|
||||
output = "[true, false, true]"
|
||||
result = parse_json_list(output)
|
||||
assert result == [True, False, True]
|
||||
|
||||
def test_list_with_numeric_values(self):
|
||||
"""Test parsing list with various numeric types."""
|
||||
output = "[42, 3.14, -10]"
|
||||
result = parse_json_list(output)
|
||||
assert result == [42, 3.14, -10]
|
||||
|
||||
def test_empty_list(self):
|
||||
"""Test parsing an empty list."""
|
||||
output = "[]"
|
||||
result = parse_json_list(output)
|
||||
assert result == []
|
||||
|
||||
def test_single_item_list(self):
|
||||
"""Test parsing list with single item."""
|
||||
output = '["N/A"]'
|
||||
def test_list_with_unicode(self):
|
||||
"""Test parsing list with unicode characters."""
|
||||
output = '["café", "😀"]'
|
||||
result = parse_json_list(output)
|
||||
assert result == ["N/A"]
|
||||
assert result == ["café", "😀"]
|
||||
|
||||
def test_no_list_raises_error(self):
|
||||
"""Test that ValueError is raised when no list is found."""
|
||||
output = "This is just text without any JSON"
|
||||
with pytest.raises(ValueError, match="No valid JSON list found"):
|
||||
parse_json_list(output)
|
||||
def test_list_with_dicts(self):
|
||||
"""Test parsing list containing dictionaries."""
|
||||
output = '[{"key1": "value1"}, {"key2": "value2"}]'
|
||||
result = parse_json_list(output)
|
||||
assert result == [{"key1": "value1"}, {"key2": "value2"}]
|
||||
|
||||
def test_only_dict_raises_error(self):
|
||||
"""Test that ValueError is raised when only a dict is found."""
|
||||
output = '{"field": "value"}'
|
||||
with pytest.raises(ValueError, match="No valid JSON list found"):
|
||||
parse_json_list(output)
|
||||
def test_list_with_field_name_normalization(self):
|
||||
"""Test list normalization with field_name."""
|
||||
# PAYER_STATE is list[str] in mapping
|
||||
output = '["IL", "CA"]'
|
||||
result = parse_json_list(output, field_name="PAYER_STATE")
|
||||
assert result == ["IL", "CA"]
|
||||
assert all(isinstance(item, str) for item in result)
|
||||
|
||||
def test_malformed_json_raises_error(self):
|
||||
"""Test that ValueError is raised for malformed JSON."""
|
||||
output = '["item1", "item2"' # Missing closing bracket
|
||||
with pytest.raises(ValueError, match="No valid JSON list found"):
|
||||
parse_json_list(output)
|
||||
|
||||
def test_non_string_input_raises_error(self):
|
||||
"""Test that TypeError is raised for non-string input."""
|
||||
with pytest.raises(TypeError, match="Expected string input"):
|
||||
parse_json_list([1, 2, 3])
|
||||
|
||||
|
||||
class TestParseJsonDictOrList:
|
||||
"""Tests for parse_json_dict_or_list function."""
|
||||
|
||||
def test_returns_dict(self):
|
||||
"""Test that it returns a dict when dict is present."""
|
||||
output = '{"field": "value"}'
|
||||
result = parse_json_dict_or_list(output)
|
||||
assert result == {"field": "value"}
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_returns_list(self):
|
||||
"""Test that it returns a list when list is present."""
|
||||
output = '["value1", "value2"]'
|
||||
result = parse_json_dict_or_list(output)
|
||||
assert result == ["value1", "value2"]
|
||||
def test_list_with_expected_format(self):
|
||||
"""Test list normalization with expected_format."""
|
||||
output = '[{"NAME": "Provider"}]'
|
||||
result = parse_json_list(
|
||||
output, field_name="PROV_INFO_JSON", expected_format="list[dict[str,str]]"
|
||||
)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_returns_last_when_both_present(self):
|
||||
"""Test that it returns the last JSON object when both types present."""
|
||||
output = '{"first": 1} and then ["second"]'
|
||||
result = parse_json_dict_or_list(output)
|
||||
assert result == ["second"]
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_returns_last_dict_when_multiple_dicts(self):
|
||||
"""Test that it returns the last dict when multiple dicts present."""
|
||||
output = '{"first": 1} and {"second": 2}'
|
||||
result = parse_json_dict_or_list(output)
|
||||
assert result == {"second": 2}
|
||||
|
||||
def test_no_json_raises_error(self):
|
||||
"""Test that ValueError is raised when no JSON is found."""
|
||||
output = "No JSON here"
|
||||
with pytest.raises(ValueError, match="No valid JSON object found"):
|
||||
parse_json_dict_or_list(output)
|
||||
|
||||
def test_non_string_input_raises_error(self):
|
||||
"""Test that TypeError is raised for non-string input."""
|
||||
with pytest.raises(TypeError, match="Expected string input"):
|
||||
parse_json_dict_or_list(None)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], dict)
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests for edge cases and special scenarios."""
|
||||
"""Tests for edge cases and error handling."""
|
||||
|
||||
def test_json_with_curly_braces_in_strings(self):
|
||||
"""Test parsing JSON containing braces in string values."""
|
||||
output = '{"template": "Use {variable} here"}'
|
||||
def test_invalid_json_dict(self):
|
||||
"""Test that invalid JSON raises ValueError."""
|
||||
output = '{"invalid": json}'
|
||||
with pytest.raises(ValueError):
|
||||
parse_json_dict(output)
|
||||
|
||||
def test_invalid_json_list(self):
|
||||
"""Test that invalid JSON raises ValueError."""
|
||||
output = "[invalid json]"
|
||||
with pytest.raises(ValueError):
|
||||
parse_json_list(output)
|
||||
|
||||
def test_non_string_input_dict(self):
|
||||
"""Test that non-string input raises TypeError."""
|
||||
with pytest.raises(TypeError):
|
||||
parse_json_dict(123)
|
||||
|
||||
def test_non_string_input_list(self):
|
||||
"""Test that non-string input raises TypeError."""
|
||||
with pytest.raises(TypeError):
|
||||
parse_json_list(123)
|
||||
|
||||
def test_empty_string_dict(self):
|
||||
"""Test that empty string raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
parse_json_dict("")
|
||||
|
||||
def test_empty_string_list(self):
|
||||
"""Test that empty string raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
parse_json_list("")
|
||||
|
||||
def test_dict_with_list_values(self):
|
||||
"""Test dict with list values that need normalization."""
|
||||
output = '{"PAYER_STATE": "IL"}'
|
||||
result = parse_json_dict(output, field_names=["PAYER_STATE"])
|
||||
# PAYER_STATE is list[str] in mapping, so string should become list
|
||||
assert result["PAYER_STATE"] == ["IL"]
|
||||
assert isinstance(result["PAYER_STATE"], list)
|
||||
|
||||
|
||||
class TestFieldAwareNormalization:
|
||||
"""Tests for field-aware normalization functionality."""
|
||||
|
||||
def test_normalize_str_field_from_string(self):
|
||||
"""Test that str fields remain strings."""
|
||||
output = '{"CONTRACT_TITLE": "My Contract"}'
|
||||
result = parse_json_dict(output, field_names=["CONTRACT_TITLE"])
|
||||
assert result["CONTRACT_TITLE"] == "My Contract"
|
||||
assert isinstance(result["CONTRACT_TITLE"], str)
|
||||
|
||||
def test_normalize_str_field_from_list(self):
|
||||
"""Test that str fields convert single-element lists to strings."""
|
||||
output = '{"CONTRACT_TITLE": ["My Contract"]}'
|
||||
result = parse_json_dict(output, field_names=["CONTRACT_TITLE"])
|
||||
assert result["CONTRACT_TITLE"] == "My Contract"
|
||||
assert isinstance(result["CONTRACT_TITLE"], str)
|
||||
|
||||
def test_normalize_list_str_field_from_string(self):
|
||||
"""Test that list[str] fields convert strings to lists."""
|
||||
output = '{"PAYER_STATE": "IL"}'
|
||||
result = parse_json_dict(output, field_names=["PAYER_STATE"])
|
||||
assert result["PAYER_STATE"] == ["IL"]
|
||||
assert isinstance(result["PAYER_STATE"], list)
|
||||
assert all(isinstance(item, str) for item in result["PAYER_STATE"])
|
||||
|
||||
def test_normalize_list_str_field_from_list(self):
|
||||
"""Test that list[str] fields keep lists as lists."""
|
||||
output = '{"PAYER_STATE": ["IL", "CA"]}'
|
||||
result = parse_json_dict(output, field_names=["PAYER_STATE"])
|
||||
assert result["PAYER_STATE"] == ["IL", "CA"]
|
||||
assert isinstance(result["PAYER_STATE"], list)
|
||||
|
||||
def test_normalize_list_dict_field(self):
|
||||
"""Test normalization of list[dict[str,str]] fields."""
|
||||
output = '{"PROV_INFO_JSON": [{"NAME": "Provider", "TIN": "123"}]}'
|
||||
result = parse_json_dict(output, field_names=["PROV_INFO_JSON"])
|
||||
assert isinstance(result["PROV_INFO_JSON"], list)
|
||||
assert len(result["PROV_INFO_JSON"]) == 1
|
||||
assert isinstance(result["PROV_INFO_JSON"][0], dict)
|
||||
assert result["PROV_INFO_JSON"][0] == {"NAME": "Provider", "TIN": "123"}
|
||||
|
||||
def test_normalize_string_representation_of_list(self):
|
||||
"""Test normalization of string representation of list."""
|
||||
# Sometimes LLM returns string representation of list
|
||||
output = "{\"PAYER_STATE\": \"['IL', 'CA']\"}"
|
||||
result = parse_json_dict(output, field_names=["PAYER_STATE"])
|
||||
assert result["PAYER_STATE"] == ["IL", "CA"]
|
||||
assert isinstance(result["PAYER_STATE"], list)
|
||||
|
||||
def test_normalize_string_representation_of_dict(self):
|
||||
"""Test normalization of string representation of dict."""
|
||||
output = '{"PROV_INFO_JSON": "[{\\"NAME\\": \\"Provider\\"}]"}'
|
||||
result = parse_json_dict(output, field_names=["PROV_INFO_JSON"])
|
||||
assert isinstance(result["PROV_INFO_JSON"], list)
|
||||
assert len(result["PROV_INFO_JSON"]) == 1
|
||||
assert isinstance(result["PROV_INFO_JSON"][0], dict)
|
||||
|
||||
def test_list_normalization_with_field_name(self):
|
||||
"""Test list normalization when field_name is provided."""
|
||||
output = '["IL", "CA"]'
|
||||
result = parse_json_list(output, field_name="PAYER_STATE")
|
||||
assert result == ["IL", "CA"]
|
||||
assert all(isinstance(item, str) for item in result)
|
||||
|
||||
def test_list_normalization_without_field_name(self):
|
||||
"""Test list normalization when field_name is not provided."""
|
||||
output = '[{"NAME": "Provider"}]'
|
||||
result = parse_json_list(output) # No field_name
|
||||
# Should normalize dicts in the list
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], dict)
|
||||
|
||||
def test_normalize_pipe_delimited_string(self):
|
||||
"""Test normalization of pipe-delimited string to list."""
|
||||
output = '{"PAYER_STATE": "IL|CA"}'
|
||||
result = parse_json_dict(output, field_names=["PAYER_STATE"])
|
||||
assert result["PAYER_STATE"] == ["IL", "CA"]
|
||||
assert isinstance(result["PAYER_STATE"], list)
|
||||
|
||||
def test_normalization_handles_empty_values(self):
|
||||
"""Test normalization of empty values."""
|
||||
output = '{"PAYER_STATE": ""}'
|
||||
result = parse_json_dict(output, field_names=["PAYER_STATE"])
|
||||
assert result["PAYER_STATE"] == []
|
||||
|
||||
def test_normalization_handles_none_values(self):
|
||||
"""Test normalization of None values."""
|
||||
output = '{"PAYER_STATE": null}'
|
||||
result = parse_json_dict(output, field_names=["PAYER_STATE"])
|
||||
assert result["PAYER_STATE"] == []
|
||||
|
||||
def test_normalization_handles_missing_fields(self):
|
||||
"""Test that missing fields don't cause errors."""
|
||||
output = '{"OTHER_FIELD": "value"}'
|
||||
result = parse_json_dict(output, field_names=["PAYER_STATE"])
|
||||
# PAYER_STATE not in output, should not cause error
|
||||
assert "OTHER_FIELD" in result
|
||||
|
||||
def test_dict_normalization_with_vs_without_field_names(self):
|
||||
"""Test difference between providing field_names vs not."""
|
||||
output = '{"PAYER_STATE": "IL", "UNKNOWN_FIELD": ["a", "b"]}'
|
||||
|
||||
# With field_names - only specified fields normalized
|
||||
result_with = parse_json_dict(output, field_names=["PAYER_STATE"])
|
||||
assert result_with["PAYER_STATE"] == ["IL"] # Normalized (in mapping)
|
||||
# UNKNOWN_FIELD not in field_names and not in mapping, so preserved as-is
|
||||
assert result_with["UNKNOWN_FIELD"] == ["a", "b"] # Preserved
|
||||
|
||||
# Without field_names - only fields in mapping are normalized
|
||||
result_without = parse_json_dict(output)
|
||||
assert result_without["PAYER_STATE"] == ["IL"] # Normalized (in mapping)
|
||||
assert result_without["UNKNOWN_FIELD"] == [
|
||||
"a",
|
||||
"b",
|
||||
] # Preserved (not in mapping)
|
||||
|
||||
def test_list_normalization_empty_list(self):
|
||||
"""Test normalization of empty list."""
|
||||
output = "[]"
|
||||
result = parse_json_list(output, field_name="PAYER_STATE")
|
||||
assert result == []
|
||||
|
||||
def test_list_normalization_single_item(self):
|
||||
"""Test normalization of single-item list."""
|
||||
output = '["IL"]'
|
||||
result = parse_json_list(output, field_name="PAYER_STATE")
|
||||
assert result == ["IL"]
|
||||
|
||||
def test_dict_normalization_preserves_order(self):
|
||||
"""Test that field order is preserved during normalization."""
|
||||
output = '{"A": "1", "B": "2", "C": "3"}'
|
||||
result = parse_json_dict(output)
|
||||
assert result == {"template": "Use {variable} here"}
|
||||
|
||||
def test_json_with_brackets_in_strings(self):
|
||||
"""Test parsing JSON containing brackets in string values."""
|
||||
output = '["[optional]", "[required]"]'
|
||||
result = parse_json_list(output)
|
||||
assert result == ["[optional]", "[required]"]
|
||||
|
||||
def test_json_with_escaped_quotes(self):
|
||||
"""Test parsing JSON with escaped quotes in strings."""
|
||||
output = '{"quote": "He said \\"hello\\""}'
|
||||
result = parse_json_dict(output)
|
||||
assert result == {"quote": 'He said "hello"'}
|
||||
|
||||
def test_json_with_backslashes(self):
|
||||
"""Test parsing JSON with backslashes."""
|
||||
output = '{"path": "C:\\\\Users\\\\file.txt"}'
|
||||
result = parse_json_dict(output)
|
||||
assert result == {"path": "C:\\Users\\file.txt"}
|
||||
|
||||
def test_very_long_string_values(self):
|
||||
"""Test parsing JSON with very long string values."""
|
||||
long_text = "x" * 10000
|
||||
output = f'{{"text": "{long_text}"}}'
|
||||
result = parse_json_dict(output)
|
||||
assert result == {"text": long_text}
|
||||
|
||||
def test_deeply_nested_structure(self):
|
||||
"""Test parsing deeply nested JSON structure."""
|
||||
output = '{"a": {"b": {"c": {"d": "value"}}}}'
|
||||
result = parse_json_dict(output)
|
||||
assert result == {"a": {"b": {"c": {"d": "value"}}}}
|
||||
|
||||
def test_json_in_markdown_code_block(self):
|
||||
"""Test parsing JSON within markdown code blocks."""
|
||||
output = """
|
||||
```json
|
||||
{"field": "value"}
|
||||
```
|
||||
"""
|
||||
result = parse_json_dict(output)
|
||||
assert result == {"field": "value"}
|
||||
|
||||
def test_json_with_reasoning_text(self):
|
||||
"""Test realistic LLM output with reasoning and JSON."""
|
||||
output = """
|
||||
Based on the analysis, I found the following information:
|
||||
1. The exhibit clearly states the LOB
|
||||
2. Multiple programs are mentioned
|
||||
|
||||
Here is my answer:
|
||||
{"LOB": ["Medicare", "Duals"], "PROGRAM": ["MA", "MASNP"]}
|
||||
"""
|
||||
result = parse_json_dict(output)
|
||||
assert result == {"LOB": ["Medicare", "Duals"], "PROGRAM": ["MA", "MASNP"]}
|
||||
|
||||
def test_json_with_explanation_in_chinese(self):
|
||||
"""Test parsing with non-English explanatory text."""
|
||||
output = '这是答案: {"field": "value"}'
|
||||
result = parse_json_dict(output)
|
||||
assert result == {"field": "value"}
|
||||
keys = list(result.keys())
|
||||
assert keys == ["A", "B", "C"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
Format normalization utilities for LLM output.
|
||||
|
||||
This module provides functions to normalize field values to their expected
|
||||
format types based on the field format mapping configuration.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from src.constants.investment_columns import FIELD_FORMAT_MAPPING
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_field_value(field_name: str, value: Any, format_type: str) -> Any:
|
||||
"""
|
||||
Normalize a field value to the expected format type.
|
||||
|
||||
Args:
|
||||
field_name: Name of the field being normalized (for logging)
|
||||
value: The value to normalize (can be str, list, dict, etc.)
|
||||
format_type: Expected format type ('str', 'list[str]', 'list[dict[str,str]]', etc.)
|
||||
|
||||
Returns:
|
||||
Normalized value in the expected format
|
||||
|
||||
Examples:
|
||||
normalize_field_value("PAYER_STATE", "IL", "list[str]") -> ["IL"]
|
||||
normalize_field_value("PAYER_STATE", ["IL", "CA"], "list[str]") -> ["IL", "CA"]
|
||||
normalize_field_value("CONTRACT_TITLE", ["Title"], "str") -> "Title"
|
||||
normalize_field_value("PROV_INFO_JSON", {...}, "list[dict[str,str]]") -> [{...}]
|
||||
"""
|
||||
if value is None:
|
||||
return _get_default_for_format(format_type)
|
||||
|
||||
# Handle string representations of lists/dicts first
|
||||
if isinstance(value, str) and value.strip():
|
||||
# Try to parse as JSON first
|
||||
if value.strip().startswith(("[", "{")):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return normalize_field_value(field_name, parsed, format_type)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
# Try to parse as Python literal
|
||||
try:
|
||||
parsed = ast.literal_eval(value)
|
||||
if parsed != value: # Only use if it actually parsed something different
|
||||
return normalize_field_value(field_name, parsed, format_type)
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
|
||||
# Normalize based on format type
|
||||
if format_type == "str":
|
||||
return _normalize_to_str(value)
|
||||
elif format_type == "list[str]":
|
||||
return _normalize_to_list_str(value)
|
||||
elif format_type == "list[dict[str,str]]":
|
||||
return _normalize_to_list_dict(value)
|
||||
elif format_type == "dict[str,str]":
|
||||
return _normalize_to_dict(value)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unknown format type '{format_type}' for field '{field_name}'. "
|
||||
f"Returning value as-is."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_to_str(value: Any) -> str:
|
||||
"""Normalize value to a string."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
elif isinstance(value, list):
|
||||
if len(value) == 0:
|
||||
return ""
|
||||
elif len(value) == 1:
|
||||
return str(value[0])
|
||||
else:
|
||||
# Join multiple values with a delimiter (pipe for backward compatibility)
|
||||
return "|".join(str(v) for v in value)
|
||||
elif isinstance(value, dict):
|
||||
# Convert dict to string representation
|
||||
return json.dumps(value)
|
||||
else:
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
|
||||
def _normalize_to_list_str(value: Any) -> list[str]:
|
||||
"""Normalize value to a list of strings."""
|
||||
if isinstance(value, list):
|
||||
# Ensure all elements are strings
|
||||
return [str(v) if v is not None else "" for v in value]
|
||||
elif isinstance(value, str):
|
||||
if not value or value.strip() == "":
|
||||
return []
|
||||
# Try to parse as JSON list first
|
||||
if value.strip().startswith("["):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, list):
|
||||
return [str(v) if v is not None else "" for v in parsed]
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
# Try to parse as Python literal
|
||||
try:
|
||||
parsed = ast.literal_eval(value)
|
||||
if isinstance(parsed, list):
|
||||
return [str(v) if v is not None else "" for v in parsed]
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
# Split on pipe delimiter (backward compatibility)
|
||||
if "|" in value:
|
||||
return [v.strip() for v in value.split("|") if v.strip()]
|
||||
# Single string value - wrap in list
|
||||
return [value]
|
||||
elif isinstance(value, dict):
|
||||
# Convert dict values to list
|
||||
return [str(v) if v is not None else "" for v in value.values()]
|
||||
else:
|
||||
return [str(value)] if value is not None else []
|
||||
|
||||
|
||||
def _normalize_to_list_dict(value: Any) -> list[dict[str, str]]:
|
||||
"""Normalize value to a list of dictionaries with string keys and values."""
|
||||
if isinstance(value, list):
|
||||
result = []
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
# Ensure all values are strings
|
||||
normalized_dict = {
|
||||
str(k): str(v) if v is not None else "" for k, v in item.items()
|
||||
}
|
||||
result.append(normalized_dict)
|
||||
elif isinstance(item, str):
|
||||
# Try to parse string as dict
|
||||
try:
|
||||
parsed = json.loads(item)
|
||||
if isinstance(parsed, dict):
|
||||
normalized_dict = {
|
||||
str(k): str(v) if v is not None else ""
|
||||
for k, v in parsed.items()
|
||||
}
|
||||
result.append(normalized_dict)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
logger.warning(
|
||||
f"Could not parse string as dict in list: {item[:50]}..."
|
||||
)
|
||||
return result
|
||||
elif isinstance(value, dict):
|
||||
# Single dict - wrap in list
|
||||
normalized_dict = {
|
||||
str(k): str(v) if v is not None else "" for k, v in value.items()
|
||||
}
|
||||
return [normalized_dict]
|
||||
elif isinstance(value, str):
|
||||
if not value or value.strip() == "":
|
||||
return []
|
||||
# Try to parse as JSON
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return _normalize_to_list_dict(parsed)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
try:
|
||||
parsed = ast.literal_eval(value)
|
||||
return _normalize_to_list_dict(parsed)
|
||||
except (ValueError, SyntaxError):
|
||||
logger.warning(f"Could not parse string as list[dict]: {value[:50]}...")
|
||||
return []
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_to_dict(value: Any) -> dict[str, str]:
|
||||
"""Normalize value to a dictionary with string keys and values."""
|
||||
if isinstance(value, dict):
|
||||
return {str(k): str(v) if v is not None else "" for k, v in value.items()}
|
||||
elif isinstance(value, str):
|
||||
if not value or value.strip() == "":
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return {
|
||||
str(k): str(v) if v is not None else "" for k, v in parsed.items()
|
||||
}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
try:
|
||||
parsed = ast.literal_eval(value)
|
||||
if isinstance(parsed, dict):
|
||||
return {
|
||||
str(k): str(v) if v is not None else ""
|
||||
for k, v in parsed.items()
|
||||
}
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
return {}
|
||||
elif isinstance(value, list):
|
||||
# If list has one dict, return it
|
||||
if len(value) == 1 and isinstance(value[0], dict):
|
||||
return {
|
||||
str(k): str(v) if v is not None else "" for k, v in value[0].items()
|
||||
}
|
||||
return {}
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_default_for_format(format_type: str) -> Any:
|
||||
"""Get default value for a given format type."""
|
||||
if format_type == "str":
|
||||
return ""
|
||||
elif format_type == "list[str]":
|
||||
return []
|
||||
elif format_type == "list[dict[str,str]]":
|
||||
return []
|
||||
elif format_type == "dict[str,str]":
|
||||
return {}
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def normalize_dict_fields(
|
||||
parsed_dict: dict[str, Any], field_names: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Normalize all fields in a dictionary based on field format mapping.
|
||||
|
||||
Args:
|
||||
parsed_dict: Dictionary with parsed LLM output
|
||||
field_names: Optional list of field names to normalize. If None, normalizes
|
||||
all fields that exist in the format mapping. Fields not in the mapping
|
||||
are left unchanged.
|
||||
|
||||
Returns:
|
||||
Dictionary with normalized field values
|
||||
"""
|
||||
normalized = {}
|
||||
fields_to_normalize = field_names if field_names else list(parsed_dict.keys())
|
||||
|
||||
for field_name in fields_to_normalize:
|
||||
if field_name not in parsed_dict:
|
||||
continue
|
||||
|
||||
# Only normalize if field is in the format mapping
|
||||
if field_name in FIELD_FORMAT_MAPPING:
|
||||
format_type = FIELD_FORMAT_MAPPING[field_name]
|
||||
value = parsed_dict[field_name]
|
||||
normalized[field_name] = normalize_field_value(
|
||||
field_name, value, format_type
|
||||
)
|
||||
else:
|
||||
# Field not in mapping, preserve original value
|
||||
normalized[field_name] = parsed_dict[field_name]
|
||||
|
||||
# Preserve any fields not processed
|
||||
for key, value in parsed_dict.items():
|
||||
if key not in normalized:
|
||||
normalized[key] = value
|
||||
|
||||
return normalized
|
||||
+85
-84
@@ -3,6 +3,9 @@ JSON parsing utilities for LLM responses.
|
||||
|
||||
This module provides robust parsers for extracting JSON dictionaries and lists
|
||||
from raw LLM output, replacing the deprecated pipe-delimited format.
|
||||
|
||||
The parsers can optionally normalize field values based on the field format
|
||||
mapping configuration to ensure consistent output formats.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -10,11 +13,15 @@ import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from src.utils.formatting_utils import normalize_dict_fields
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_json_dict(raw_llm_output: str) -> dict[str, Any]:
|
||||
def parse_json_dict(
|
||||
raw_llm_output: str, field_names: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Extract and parse a JSON dictionary from raw LLM output.
|
||||
|
||||
@@ -22,12 +29,18 @@ def parse_json_dict(raw_llm_output: str) -> dict[str, Any]:
|
||||
raw output string and returns it as a Python dict. It handles common cases
|
||||
where the LLM includes explanatory text before or after the JSON.
|
||||
|
||||
Optionally normalizes field values based on the field format mapping
|
||||
configuration to ensure consistent output formats.
|
||||
|
||||
Args:
|
||||
raw_llm_output: Raw string output from the LLM, potentially containing
|
||||
explanatory text along with JSON dictionary.
|
||||
field_names: Optional list of field names to normalize. If provided,
|
||||
field values will be normalized according to the field format mapping.
|
||||
|
||||
Returns:
|
||||
dict: The parsed JSON dictionary.
|
||||
dict: The parsed JSON dictionary, with normalized field values if
|
||||
field_names is provided.
|
||||
|
||||
Raises:
|
||||
ValueError: If no valid JSON dictionary is found in the output.
|
||||
@@ -38,9 +51,9 @@ def parse_json_dict(raw_llm_output: str) -> dict[str, Any]:
|
||||
>>> parse_json_dict(output)
|
||||
{'field': 'value', 'count': 42}
|
||||
|
||||
>>> output = '{"key": "value"} This is some explanation text.'
|
||||
>>> parse_json_dict(output)
|
||||
{'key': 'value'}
|
||||
>>> output = '{"PAYER_STATE": "IL"}'
|
||||
>>> parse_json_dict(output, field_names=["PAYER_STATE"])
|
||||
{'PAYER_STATE': ['IL']} # Normalized to list[str]
|
||||
"""
|
||||
if not isinstance(raw_llm_output, str):
|
||||
raise TypeError(f"Expected string input, got {type(raw_llm_output).__name__}")
|
||||
@@ -91,7 +104,18 @@ def parse_json_dict(raw_llm_output: str) -> dict[str, Any]:
|
||||
|
||||
if found_dicts:
|
||||
# Return the last valid dictionary found
|
||||
return found_dicts[-1]
|
||||
parsed_dict = found_dicts[-1]
|
||||
|
||||
# Always normalize field values based on format mapping
|
||||
# If field_names provided, normalize only those fields
|
||||
# Otherwise, normalize all fields found in the parsed dict
|
||||
if field_names:
|
||||
parsed_dict = normalize_dict_fields(parsed_dict, field_names)
|
||||
else:
|
||||
# Normalize all fields in the parsed dict
|
||||
parsed_dict = normalize_dict_fields(parsed_dict, field_names=None)
|
||||
|
||||
return parsed_dict
|
||||
|
||||
raise ValueError(
|
||||
f"No valid JSON dictionary found in LLM output. "
|
||||
@@ -99,7 +123,11 @@ def parse_json_dict(raw_llm_output: str) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def parse_json_list(raw_llm_output: str) -> list[Any]:
|
||||
def parse_json_list(
|
||||
raw_llm_output: str,
|
||||
field_name: str | None = None,
|
||||
expected_format: str | None = None,
|
||||
) -> list[Any]:
|
||||
"""
|
||||
Extract and parse a JSON list (array) from raw LLM output.
|
||||
|
||||
@@ -107,12 +135,19 @@ def parse_json_list(raw_llm_output: str) -> list[Any]:
|
||||
string and returns it as a Python list. It handles common cases where
|
||||
the LLM includes explanatory text before or after the JSON.
|
||||
|
||||
Optionally normalizes the list contents based on the field format mapping
|
||||
if the list contains dictionaries that need field normalization.
|
||||
|
||||
Args:
|
||||
raw_llm_output: Raw string output from the LLM, potentially containing
|
||||
explanatory text along with JSON list.
|
||||
field_name: Optional field name for normalization. If the list contains
|
||||
dictionaries, their fields will be normalized based on the format mapping.
|
||||
expected_format: Optional expected format type (e.g., 'list[dict[str,str]]').
|
||||
If not provided, will be looked up from field format mapping using field_name.
|
||||
|
||||
Returns:
|
||||
list: The parsed JSON list.
|
||||
list: The parsed JSON list, with normalized contents if field_name is provided.
|
||||
|
||||
Raises:
|
||||
ValueError: If no valid JSON list is found in the output.
|
||||
@@ -123,9 +158,9 @@ def parse_json_list(raw_llm_output: str) -> list[Any]:
|
||||
>>> parse_json_list(output)
|
||||
['value1', 'value2', 'value3']
|
||||
|
||||
>>> output = '["item1", "item2"] as you can see above'
|
||||
>>> parse_json_list(output)
|
||||
['item1', 'item2']
|
||||
>>> output = '[{"TIN": "123", "NPI": "456"}]'
|
||||
>>> parse_json_list(output, field_name="PROV_INFO_JSON")
|
||||
[{'TIN': '123', 'NPI': '456'}] # Normalized if needed
|
||||
"""
|
||||
if not isinstance(raw_llm_output, str):
|
||||
raise TypeError(f"Expected string input, got {type(raw_llm_output).__name__}")
|
||||
@@ -176,81 +211,47 @@ def parse_json_list(raw_llm_output: str) -> list[Any]:
|
||||
|
||||
if found_lists:
|
||||
# Return the last valid list found
|
||||
return found_lists[-1]
|
||||
parsed_list = found_lists[-1]
|
||||
|
||||
# Always normalize list contents based on format mapping
|
||||
# If field_name provided, use it to determine format
|
||||
# Otherwise, try to infer format from list contents
|
||||
from src.constants.investment_columns import FIELD_FORMAT_MAPPING
|
||||
from src.utils.formatting_utils import normalize_field_value
|
||||
|
||||
if field_name:
|
||||
# Check if field is in mapping
|
||||
if field_name in FIELD_FORMAT_MAPPING:
|
||||
format_type = expected_format or FIELD_FORMAT_MAPPING[field_name]
|
||||
normalized_list = normalize_field_value(
|
||||
field_name, parsed_list, format_type
|
||||
)
|
||||
return normalized_list
|
||||
else:
|
||||
# Field not in mapping, but if expected_format provided, use it
|
||||
if expected_format:
|
||||
normalized_list = normalize_field_value(
|
||||
field_name, parsed_list, expected_format
|
||||
)
|
||||
return normalized_list
|
||||
# Otherwise return as-is
|
||||
return parsed_list
|
||||
else:
|
||||
# If no field_name, check if list contains dicts and normalize them
|
||||
if parsed_list and isinstance(parsed_list[0], dict):
|
||||
# Normalize each dict in the list
|
||||
normalized_list = []
|
||||
for item in parsed_list:
|
||||
if isinstance(item, dict):
|
||||
normalized_item = normalize_dict_fields(item, field_names=None)
|
||||
normalized_list.append(normalized_item)
|
||||
else:
|
||||
normalized_list.append(item)
|
||||
return normalized_list
|
||||
|
||||
return parsed_list
|
||||
|
||||
raise ValueError(
|
||||
f"No valid JSON list found in LLM output. "
|
||||
f"Output preview: {raw_llm_output[:200]}..."
|
||||
)
|
||||
|
||||
|
||||
def parse_json_dict_or_list(raw_llm_output: str) -> dict[str, Any] | list[Any]:
|
||||
"""
|
||||
Extract and parse either a JSON dictionary or list from raw LLM output.
|
||||
|
||||
This is a convenience function that attempts to parse the output as either
|
||||
a dictionary or a list, returning whichever is found last in the output.
|
||||
Useful when the expected output format may vary.
|
||||
|
||||
Args:
|
||||
raw_llm_output: Raw string output from the LLM.
|
||||
|
||||
Returns:
|
||||
dict | list: The parsed JSON object (dictionary or list).
|
||||
|
||||
Raises:
|
||||
ValueError: If no valid JSON dictionary or list is found in the output.
|
||||
TypeError: If raw_llm_output is not a string.
|
||||
"""
|
||||
if not isinstance(raw_llm_output, str):
|
||||
raise TypeError(f"Expected string input, got {type(raw_llm_output).__name__}")
|
||||
|
||||
found_objects = []
|
||||
i = 0
|
||||
|
||||
while i < len(raw_llm_output):
|
||||
if raw_llm_output[i] in "{[":
|
||||
start = i
|
||||
stack = [raw_llm_output[i]]
|
||||
j = i + 1
|
||||
|
||||
while j < len(raw_llm_output):
|
||||
# Skip over string literals
|
||||
if raw_llm_output[j] == '"':
|
||||
j += 1
|
||||
while j < len(raw_llm_output):
|
||||
if raw_llm_output[j] == "\\" and j + 1 < len(raw_llm_output):
|
||||
j += 2
|
||||
continue
|
||||
if raw_llm_output[j] == '"':
|
||||
break
|
||||
j += 1
|
||||
elif raw_llm_output[j] in "{[":
|
||||
stack.append(raw_llm_output[j])
|
||||
elif raw_llm_output[j] in "}]":
|
||||
if not stack:
|
||||
break
|
||||
open_bracket = stack.pop()
|
||||
if (open_bracket == "{" and raw_llm_output[j] != "}") or (
|
||||
open_bracket == "[" and raw_llm_output[j] != "]"
|
||||
):
|
||||
break
|
||||
if not stack:
|
||||
candidate = raw_llm_output[start : j + 1]
|
||||
try:
|
||||
obj = json.loads(candidate)
|
||||
found_objects.append(obj)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
i = j
|
||||
break
|
||||
j += 1
|
||||
i += 1
|
||||
|
||||
if found_objects:
|
||||
return found_objects[-1]
|
||||
|
||||
raise ValueError(
|
||||
f"No valid JSON object found in LLM output. "
|
||||
f"Output preview: {raw_llm_output[:200]}..."
|
||||
)
|
||||
|
||||
@@ -806,101 +806,3 @@ def normalize_to_json_list(val):
|
||||
result = json.dumps(expanded)
|
||||
return result
|
||||
return val
|
||||
|
||||
|
||||
def normalize_one_to_one_field_value(field_name: str, value) -> str | list:
|
||||
"""
|
||||
Normalize a 1:1 field value to ensure single-value fields are strings, not lists.
|
||||
|
||||
This function handles the common case where LLM returns a single-value field as
|
||||
a list with one element (e.g., ['The Value']). It extracts the string from such
|
||||
lists while preserving legitimate multi-value lists.
|
||||
|
||||
Args:
|
||||
field_name (str): Name of the field being normalized (for logging/debugging)
|
||||
value: The field value to normalize. Can be:
|
||||
- str: Returned as-is
|
||||
- list with 1 element: Extract the string
|
||||
- list with >1 elements: Returned unchanged (may be legitimate multi-value)
|
||||
- None/empty: Returned as-is
|
||||
|
||||
Returns:
|
||||
str | list: Normalized value:
|
||||
- If input is a list with exactly 1 element: returns that element as string
|
||||
- If input is already a string: returns as-is
|
||||
- If input is a list with >1 elements: returns list unchanged
|
||||
- If input is None or empty: returns as-is
|
||||
|
||||
Examples:
|
||||
>>> normalize_one_to_one_field_value("CONTRACT_TITLE", ["Provider Agreement"])
|
||||
'Provider Agreement'
|
||||
>>> normalize_one_to_one_field_value("PAYER_NAME", "Aetna")
|
||||
'Aetna'
|
||||
>>> normalize_one_to_one_field_value("PROVIDER_NAME", ["Provider A", "Provider B"])
|
||||
['Provider A', 'Provider B'] # Multi-value preserved
|
||||
>>> normalize_one_to_one_field_value("LOB", ["Medicare", "Medicaid"])
|
||||
['Medicare', 'Medicaid'] # Multi-value preserved
|
||||
"""
|
||||
# Handle None and empty values
|
||||
if value is None or (isinstance(value, str) and is_empty(value)):
|
||||
return value
|
||||
|
||||
# If it's already a string, return as-is
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
|
||||
# If it's a list
|
||||
if isinstance(value, list):
|
||||
# List with exactly 1 element: extract the string
|
||||
if len(value) == 1:
|
||||
return str(value[0]) if value[0] is not None else "N/A"
|
||||
# List with >1 elements: preserve (may be legitimate multi-value)
|
||||
# This handles cases like PROVIDER_NAME with multiple providers,
|
||||
# or dynamic fields (LOB, PROGRAM, PRODUCT, NETWORK) when passed to 1:1
|
||||
elif len(value) > 1:
|
||||
return value
|
||||
# Empty list: return "N/A"
|
||||
else:
|
||||
return "N/A"
|
||||
|
||||
# For any other type, convert to string
|
||||
return str(value) if value is not None else "N/A"
|
||||
|
||||
|
||||
def normalize_one_to_one_answers_dict(
|
||||
answers_dict: dict[str, str | list],
|
||||
) -> dict[str, str | list]:
|
||||
"""
|
||||
Normalize all values in a 1:1 answers dictionary.
|
||||
|
||||
Applies normalization to all fields in the dictionary, converting single-element
|
||||
lists to strings while preserving legitimate multi-value lists.
|
||||
|
||||
Args:
|
||||
answers_dict (dict): Dictionary of field names to values (may be strings or lists)
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with normalized values (single-element lists converted to strings)
|
||||
|
||||
Example:
|
||||
>>> answers = {
|
||||
... "CONTRACT_TITLE": ["Provider Agreement"],
|
||||
... "PAYER_NAME": "Aetna",
|
||||
... "PROVIDER_NAME": ["Provider A", "Provider B"]
|
||||
... }
|
||||
>>> normalize_one_to_one_answers_dict(answers)
|
||||
{
|
||||
"CONTRACT_TITLE": "Provider Agreement",
|
||||
"PAYER_NAME": "Aetna",
|
||||
"PROVIDER_NAME": ["Provider A", "Provider B"]
|
||||
}
|
||||
"""
|
||||
DO_NOT_NORMALIZE = ["PROV_INFO_JSON", "PAYER_STATE", "PROVIDER_STATE"]
|
||||
normalized = {}
|
||||
for field_name, value in answers_dict.items():
|
||||
if field_name not in DO_NOT_NORMALIZE:
|
||||
normalized[field_name] = normalize_one_to_one_field_value(field_name, value)
|
||||
else:
|
||||
normalized[field_name] = value
|
||||
|
||||
return normalized
|
||||
|
||||
Reference in New Issue
Block a user