diff --git a/src/constants/investment_columns.py b/src/constants/investment_columns.py index a919a8f..0f127a3 100644 --- a/src/constants/investment_columns.py +++ b/src/constants/investment_columns.py @@ -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", +} diff --git a/src/pipelines/clients/bcbs_promise/file_processing.py b/src/pipelines/clients/bcbs_promise/file_processing.py index 5ae30a3..ba2477a 100644 --- a/src/pipelines/clients/bcbs_promise/file_processing.py +++ b/src/pipelines/clients/bcbs_promise/file_processing.py @@ -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 diff --git a/src/pipelines/clients/bcbs_promise/prompts/prompt_calls.py b/src/pipelines/clients/bcbs_promise/prompts/prompt_calls.py index aa5d58e..9e38634 100644 --- a/src/pipelines/clients/bcbs_promise/prompts/prompt_calls.py +++ b/src/pipelines/clients/bcbs_promise/prompts/prompt_calls.py @@ -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 = {} diff --git a/src/pipelines/clients/clover/file_processing.py b/src/pipelines/clients/clover/file_processing.py index a72fc01..04f3b2f 100644 --- a/src/pipelines/clients/clover/file_processing.py +++ b/src/pipelines/clients/clover/file_processing.py @@ -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 diff --git a/src/pipelines/clients/clover/prompts/prompt_calls.py b/src/pipelines/clients/clover/prompts/prompt_calls.py index c1dfe0a..6247ca3 100644 --- a/src/pipelines/clients/clover/prompts/prompt_calls.py +++ b/src/pipelines/clients/clover/prompts/prompt_calls.py @@ -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 = {} diff --git a/src/pipelines/saas/file_processing.py b/src/pipelines/saas/file_processing.py index e44b07f..7aec330 100644 --- a/src/pipelines/saas/file_processing.py +++ b/src/pipelines/saas/file_processing.py @@ -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 ) diff --git a/src/pipelines/saas/prompts/prompt_calls.py b/src/pipelines/saas/prompts/prompt_calls.py index 9aa7bae..6a4f027 100644 --- a/src/pipelines/saas/prompts/prompt_calls.py +++ b/src/pipelines/saas/prompts/prompt_calls.py @@ -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] diff --git a/src/pipelines/shared/extraction/one_to_n_funcs.py b/src/pipelines/shared/extraction/one_to_n_funcs.py index a4b204e..56c129a 100644 --- a/src/pipelines/shared/extraction/one_to_n_funcs.py +++ b/src/pipelines/shared/extraction/one_to_n_funcs.py @@ -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 diff --git a/src/pipelines/shared/extraction/one_to_one_funcs.py b/src/pipelines/shared/extraction/one_to_one_funcs.py index 2329157..3e32d88 100644 --- a/src/pipelines/shared/extraction/one_to_one_funcs.py +++ b/src/pipelines/shared/extraction/one_to_one_funcs.py @@ -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 diff --git a/src/pipelines/shared/postprocessing/aarete_derived.py b/src/pipelines/shared/postprocessing/aarete_derived.py index 8baf3c8..0e77a55 100644 --- a/src/pipelines/shared/postprocessing/aarete_derived.py +++ b/src/pipelines/shared/postprocessing/aarete_derived.py @@ -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), diff --git a/src/pipelines/shared/postprocessing/postprocess.py b/src/pipelines/shared/postprocessing/postprocess.py index db17ec5..88addaf 100644 --- a/src/pipelines/shared/postprocessing/postprocess.py +++ b/src/pipelines/shared/postprocessing/postprocess.py @@ -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 diff --git a/src/pipelines/shared/postprocessing/postprocessing_funcs.py b/src/pipelines/shared/postprocessing/postprocessing_funcs.py index 65ca572..aecfb66 100644 --- a/src/pipelines/shared/postprocessing/postprocessing_funcs.py +++ b/src/pipelines/shared/postprocessing/postprocessing_funcs.py @@ -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() diff --git a/src/pipelines/shared/preprocessing/hybrid_smart_chunking_funcs.py b/src/pipelines/shared/preprocessing/hybrid_smart_chunking_funcs.py index 95a5fd4..9a5bc9c 100644 --- a/src/pipelines/shared/preprocessing/hybrid_smart_chunking_funcs.py +++ b/src/pipelines/shared/preprocessing/hybrid_smart_chunking_funcs.py @@ -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 = ( diff --git a/src/prompts/prompt_templates.py b/src/prompts/prompt_templates.py index adc680e..3c057c4 100644 --- a/src/prompts/prompt_templates.py +++ b/src/prompts/prompt_templates.py @@ -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: diff --git a/src/testbed/add_reimb_id_to_testbed.py b/src/testbed/add_reimb_id_to_testbed.py index 0273cb8..5698f46 100644 --- a/src/testbed/add_reimb_id_to_testbed.py +++ b/src/testbed/add_reimb_id_to_testbed.py @@ -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) diff --git a/src/testbed/testbed_utils.py b/src/testbed/testbed_utils.py index 1c88015..77c32f7 100644 --- a/src/testbed/testbed_utils.py +++ b/src/testbed/testbed_utils.py @@ -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 diff --git a/src/tests/test_json_parsers.py b/src/tests/test_json_parsers.py index 6f8f3a5..c7859d5 100644 --- a/src/tests/test_json_parsers.py +++ b/src/tests/test_json_parsers.py @@ -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"] diff --git a/src/tests/test_prompt_calls.py b/src/tests/test_prompt_calls.py new file mode 100644 index 0000000..9d3da09 --- /dev/null +++ b/src/tests/test_prompt_calls.py @@ -0,0 +1,1019 @@ +""" +Comprehensive tests for prompt_calls module. + +Tests that all prompt functions correctly normalize raw LLM responses according to +FIELD_FORMAT_MAPPING. This verifies that field-aware parsers work correctly with +realistic LLM outputs for all prompt types. +""" + +import unittest +from unittest.mock import patch, MagicMock + +from src.pipelines.saas.prompts import prompt_calls +from src.prompts import prompt_templates +from src.prompts.fieldset import FieldSet +from src.constants.constants import Constants +from src.constants.investment_columns import FIELD_FORMAT_MAPPING +import src.config as config + + +class TestPromptCalls(unittest.TestCase): + """Test that all prompt functions correctly normalize raw LLM responses.""" + + def setUp(self): + """Set up test fixtures.""" + self.constants = Constants() + self.filename = "test_contract.pdf" + self.exhibit_text = "Sample exhibit text for testing." + self.page_text = "Sample page text for testing." + + # ==================== EXHIBIT LEVEL PROMPTS ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_exhibit_level_normalizes_fields(self, mock_invoke): + """Test that prompt_exhibit_level normalizes fields based on format mapping.""" + exhibit_level_fields = FieldSet( + config.FIELD_JSON_PATH, field_type="exhibit_level" + ) + + # Get actual field names from the FieldSet + actual_field_names = [f.field_name for f in exhibit_level_fields.fields] + + # Use fields that actually exist in exhibit_level + test_fields = {} + if "CLAIM_TYPE_CD" in actual_field_names: + test_fields["CLAIM_TYPE_CD"] = ( + "Inpatient" # Should normalize to ["Inpatient"] + ) + if "CONTRACT_TITLE" in actual_field_names: + test_fields["CONTRACT_TITLE"] = [ + "My Contract Title" + ] # Should normalize to "My Contract Title" + + if not test_fields: + self.skipTest("No suitable fields found in exhibit_level FieldSet") + + # Simulate LLM returning values that need normalization + import json + + mock_invoke.return_value = json.dumps(test_fields) + + result = prompt_calls.prompt_exhibit_level( + self.exhibit_text, exhibit_level_fields, self.constants, self.filename + ) + + # Check each field against format mapping + for field_name, input_value in test_fields.items(): + expected_format = FIELD_FORMAT_MAPPING.get(field_name, "str") + actual_value = result.get(field_name) + + if expected_format == "list[str]": + self.assertIsInstance( + actual_value, + list, + f"{field_name} should be list[str] (format: {expected_format}), got {type(actual_value)}: {actual_value}", + ) + # If input was a string, it should normalize to a list with one element + if isinstance(input_value, str): + self.assertEqual(actual_value, [input_value]) + else: + self.assertEqual(actual_value, input_value) + elif expected_format == "str": + self.assertIsInstance( + actual_value, + str, + f"{field_name} should be str (format: {expected_format}), got {type(actual_value)}: {actual_value}", + ) + # If input was a list, it should normalize to a string + if isinstance(input_value, list) and len(input_value) == 1: + self.assertEqual(actual_value, str(input_value[0])) + else: + self.assertEqual( + actual_value, + ( + str(input_value) + if not isinstance(input_value, str) + else input_value + ), + ) + else: + self.fail(f"Unexpected format {expected_format} for {field_name}") + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_exhibit_level_breakout_normalizes_fields(self, mock_invoke): + """Test that prompt_exhibit_level_breakout normalizes facility adjustment fields.""" + # Simulate LLM returning facility adjustment breakout + mock_invoke.return_value = """{ + "DSH_IND": "Y", + "IME_IND": "N", + "GME_IND": "Y" + }""" + + exhibit_level_answers = {"FACILITY_ADJUSTMENT_TERM": "Some term"} + result = prompt_calls.prompt_exhibit_level_breakout( + exhibit_level_answers, self.filename + ) + + # Should return dict with normalized fields + self.assertIsInstance(result, dict) + + # Check fields against format mapping + for field_name in ["DSH_IND", "IME_IND", "GME_IND"]: + if field_name in result: + expected_format = FIELD_FORMAT_MAPPING.get(field_name, "str") + actual_value = result.get(field_name) + + if expected_format == "str": + self.assertIsInstance( + actual_value, + str, + f"{field_name} should be str (format: {expected_format}), got {type(actual_value)}", + ) + elif expected_format == "list[str]": + self.assertIsInstance( + actual_value, + list, + f"{field_name} should be list[str] (format: {expected_format}), got {type(actual_value)}", + ) + + # ==================== DYNAMIC PROMPTS ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_dynamic_primary_normalizes_list_str_field(self, mock_invoke): + """Test that prompt_dynamic_primary normalizes a list[str] field correctly.""" + # Simulate LLM returning a string representation of a list (common edge case) + mock_invoke.return_value = '["Medicare", "Medicaid"]' + + # Create a mock field for LOB (which is list[str] in format mapping) + mock_field = MagicMock() + mock_field.field_name = "LOB" + mock_field.get_prompt.return_value = "What LOB values are present?" + + result = prompt_calls.prompt_dynamic_primary( + self.exhibit_text, + mock_field, + self.constants, + self.filename, + prompt_templates.DYNAMIC_PRIMARY, + ) + + # Check expected format from mapping + expected_format = FIELD_FORMAT_MAPPING.get("LOB", "str") + if expected_format == "list[str]": + self.assertIsInstance( + result, + list, + f"LOB should be list[str] (format: {expected_format}), got {type(result)}", + ) + self.assertEqual(result, ["Medicare", "Medicaid"]) + elif expected_format == "str": + self.assertIsInstance( + result, + str, + f"LOB should be str (format: {expected_format}), got {type(result)}", + ) + else: + self.fail(f"Unexpected format {expected_format} for LOB") + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_dynamic_normalizes_multiple_fields(self, mock_invoke): + """Test that prompt_dynamic normalizes multiple fields correctly.""" + # Simulate LLM returning mixed formats + mock_invoke.return_value = """{ + "PRODUCT": ["Product1", "Product2"], + "PROGRAM": "Program1", + "NETWORK": ["Network1", "Network2"] + }""" + + field_prompts = { + "PRODUCT": "What products?", + "PROGRAM": "What program?", + "NETWORK": "What networks?", + } + + result = prompt_calls.prompt_dynamic( + self.exhibit_text, field_prompts, self.filename + ) + + # Check each field against format mapping + for field_name, expected_value in [ + ("PRODUCT", ["Product1", "Product2"]), + ("PROGRAM", ["Program1"]), + ("NETWORK", ["Network1", "Network2"]), + ]: + expected_format = FIELD_FORMAT_MAPPING.get(field_name, "str") + actual_value = result.get(field_name) + + if expected_format == "list[str]": + self.assertIsInstance( + actual_value, + list, + f"{field_name} should be list[str] (format: {expected_format}), got {type(actual_value)}", + ) + self.assertEqual(actual_value, expected_value) + elif expected_format == "str": + self.assertIsInstance( + actual_value, + str, + f"{field_name} should be str (format: {expected_format}), got {type(actual_value)}", + ) + else: + self.fail(f"Unexpected format {expected_format} for {field_name}") + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_dynamic_assignment_normalizes_field(self, mock_invoke): + """Test that prompt_dynamic_assignment normalizes the assigned field.""" + # Simulate LLM returning a dict with the field name + mock_invoke.return_value = '{"PRODUCT": ["Product1", "Product2"]}' + + mock_field = MagicMock() + mock_field.field_name = "PRODUCT" + mock_field.get_prompt.return_value = "What product?" + + result = prompt_calls.prompt_dynamic_assignment( + "Service Term", + "Reimbursement Term", + mock_field, + self.exhibit_text, + "1", + self.constants, + self.filename, + ) + + # Check PRODUCT against format mapping + expected_format = FIELD_FORMAT_MAPPING.get("PRODUCT", "str") + actual_value = result.get("PRODUCT") + + if expected_format == "list[str]": + self.assertIsInstance( + actual_value, + list, + f"PRODUCT should be list[str] (format: {expected_format}), got {type(actual_value)}", + ) + self.assertEqual(actual_value, ["Product1", "Product2"]) + elif expected_format == "str": + self.assertIsInstance( + actual_value, + str, + f"PRODUCT should be str (format: {expected_format}), got {type(actual_value)}", + ) + else: + self.fail(f"Unexpected format {expected_format} for PRODUCT") + + # ==================== REIMBURSEMENT PROMPTS ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_reimbursement_primary_normalizes_service_and_reimb_terms( + self, mock_invoke + ): + """Test that prompt_reimbursement_primary normalizes SERVICE_TERM and REIMB_TERM.""" + # Simulate LLM returning list of dicts + mock_invoke.return_value = """[ + {"SERVICE_TERM": "Service1", "REIMB_TERM": "100% of Medicare"}, + {"SERVICE_TERM": "Service2", "REIMB_TERM": "Fee Schedule"} + ]""" + + result = prompt_calls.prompt_reimbursement_primary( + self.page_text, self.filename + ) + + # Should return list of dicts + self.assertIsInstance(result, list) + self.assertEqual(len(result), 2) + + # Each dict should have normalized fields with correct types and values + expected_values = [ + {"SERVICE_TERM": "Service1", "REIMB_TERM": "100% of Medicare"}, + {"SERVICE_TERM": "Service2", "REIMB_TERM": "Fee Schedule"}, + ] + + for i, item in enumerate(result): + expected_item = expected_values[i] + # Check SERVICE_TERM and REIMB_TERM against format mapping + for field_name, expected_value in expected_item.items(): + expected_format = FIELD_FORMAT_MAPPING.get(field_name, "str") + actual_value = item.get(field_name) + + if expected_format == "str": + self.assertIsInstance( + actual_value, + str, + f"{field_name} should be str (format: {expected_format}), got {type(actual_value)}", + ) + self.assertEqual( + actual_value, + expected_value, + f"{field_name} value mismatch: expected {expected_value}, got {actual_value}", + ) + elif expected_format == "list[str]": + self.assertIsInstance( + actual_value, + list, + f"{field_name} should be list[str] (format: {expected_format}), got {type(actual_value)}", + ) + if isinstance(expected_value, str): + self.assertEqual( + actual_value, + [expected_value], + f"{field_name} value mismatch: expected [{expected_value}], got {actual_value}", + ) + else: + self.assertEqual( + actual_value, + expected_value, + f"{field_name} value mismatch: expected {expected_value}, got {actual_value}", + ) + else: + self.fail(f"Unexpected format {expected_format} for {field_name}") + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_reimbursement_primary_handles_no_results(self, mock_invoke): + """Test that prompt_reimbursement_primary handles NO_REIMBURSEMENT_TERMS_FOUND.""" + mock_invoke.return_value = "NO_REIMBURSEMENT_TERMS_FOUND" + + result = prompt_calls.prompt_reimbursement_primary( + self.page_text, self.filename + ) + + # Should return empty list + self.assertEqual(result, []) + + # ==================== METHODOLOGY BREAKOUT PROMPTS ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_methodology_breakout_normalizes_methodology_fields( + self, mock_invoke + ): + """Test that prompt_methodology_breakout normalizes methodology fields.""" + # Simulate LLM returning methodology breakout with mixed formats + mock_invoke.return_value = """[ + { + "LESSER_OF_IND": "Y", + "AARETE_DERIVED_REIMB_METHOD": "Fee Schedule", + "REIMB_FEE_RATE": "100.00", + "REIMB_PCT_RATE": "", + "UNIT_OF_MEASURE": "Per Unit" + } + ]""" + + result = prompt_calls.prompt_methodology_breakout( + "Service Term", "Reimbursement Term", self.filename + ) + + # Should return list of dicts + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + + # Check that fields are normalized correctly against format mapping + item = result[0] + expected_values = { + "LESSER_OF_IND": "Y", + "AARETE_DERIVED_REIMB_METHOD": "Fee Schedule", + "REIMB_FEE_RATE": "100.00", + "REIMB_PCT_RATE": "", + "UNIT_OF_MEASURE": "Per Unit", + } + + for field_name, expected_value in expected_values.items(): + expected_format = FIELD_FORMAT_MAPPING.get(field_name, "str") + actual_value = item.get(field_name) + + if expected_format == "str": + self.assertIsInstance( + actual_value, + str, + f"{field_name} should be str (format: {expected_format}), got {type(actual_value)}", + ) + # For numeric strings, allow minor variations + if ( + field_name in ["REIMB_FEE_RATE", "REIMB_PCT_RATE"] + and expected_value + ): + try: + expected_float = float(expected_value) + actual_float = float(actual_value) + self.assertEqual( + actual_float, + expected_float, + f"{field_name} numeric value mismatch", + ) + except (ValueError, TypeError): + self.assertEqual( + actual_value, expected_value, f"{field_name} value mismatch" + ) + else: + self.assertEqual( + actual_value, expected_value, f"{field_name} value mismatch" + ) + elif expected_format == "list[str]": + self.assertIsInstance( + actual_value, + list, + f"{field_name} should be list[str] (format: {expected_format}), got {type(actual_value)}", + ) + if isinstance(expected_value, str): + self.assertEqual( + actual_value, [expected_value], f"{field_name} value mismatch" + ) + else: + self.assertEqual( + actual_value, expected_value, f"{field_name} value mismatch" + ) + else: + self.fail(f"Unexpected format {expected_format} for {field_name}") + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_fee_schedule_breakout_normalizes_fields(self, mock_invoke): + """Test that prompt_fee_schedule_breakout normalizes fee schedule fields.""" + # Simulate LLM returning fee schedule breakout + mock_invoke.return_value = """{ + "FEE_SCHEDULE": "Medicare", + "FEE_SCHEDULE_VERSION": "2024" + }""" + + methodology_breakout_dict = {"FEE_SCHEDULE": "Medicare"} + result = prompt_calls.prompt_fee_schedule_breakout( + methodology_breakout_dict, "Fee Schedule", self.filename + ) + + # Should return dict with normalized fields + self.assertIsInstance(result, dict) + + # Check fields against format mapping + for field_name in ["FEE_SCHEDULE", "FEE_SCHEDULE_VERSION"]: + if field_name in result: + expected_format = FIELD_FORMAT_MAPPING.get(field_name, "str") + actual_value = result.get(field_name) + + if expected_format == "str": + self.assertIsInstance( + actual_value, + str, + f"{field_name} should be str (format: {expected_format}), got {type(actual_value)}", + ) + elif expected_format == "list[str]": + self.assertIsInstance( + actual_value, + list, + f"{field_name} should be list[str] (format: {expected_format}), got {type(actual_value)}", + ) + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_grouper_breakout_normalizes_fields(self, mock_invoke): + """Test that prompt_grouper_breakout normalizes grouper fields.""" + # Simulate LLM returning grouper breakout + mock_invoke.return_value = """{ + "GROUPER_TYPE": "DRG", + "GROUPER_VERSION": "2024" + }""" + + result = prompt_calls.prompt_grouper_breakout( + "Service Term", "Reimbursement Term", self.filename + ) + + # Should return dict with normalized fields + self.assertIsInstance(result, dict) + + # Check fields against format mapping + for field_name in ["GROUPER_TYPE", "GROUPER_VERSION"]: + if field_name in result: + expected_format = FIELD_FORMAT_MAPPING.get(field_name, "str") + actual_value = result.get(field_name) + + if expected_format == "str": + self.assertIsInstance( + actual_value, + str, + f"{field_name} should be str (format: {expected_format}), got {type(actual_value)}", + ) + elif expected_format == "list[str]": + self.assertIsInstance( + actual_value, + list, + f"{field_name} should be list[str] (format: {expected_format}), got {type(actual_value)}", + ) + + # ==================== SPECIAL CASE PROMPTS ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_carveout_check_normalizes_carveout_cd(self, mock_invoke): + """Test that prompt_carveout_check normalizes CARVEOUT_CD to str.""" + # Simulate LLM returning carveout code as list + mock_invoke.return_value = '["PAYMENT_CARVEOUT"]' + + result = prompt_calls.prompt_carveout_check( + "Service Term", "Reimbursement Term", self.filename + ) + + # CARVEOUT_CD is str in format mapping, so should normalize list to string + expected_format = FIELD_FORMAT_MAPPING.get("CARVEOUT_CD", "str") + if expected_format == "str": + self.assertIsInstance( + result, + str, + f"CARVEOUT_CD should be str (format: {expected_format}), got {type(result)}", + ) + self.assertEqual(result, "PAYMENT_CARVEOUT") + elif expected_format == "list[str]": + self.assertIsInstance( + result, + list, + f"CARVEOUT_CD should be list[str] (format: {expected_format}), got {type(result)}", + ) + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_special_case_breakout_normalizes_fields(self, mock_invoke): + """Test that prompt_special_case_breakout normalizes breakout fields.""" + # Simulate LLM returning facility adjustment breakout + mock_invoke.return_value = """{ + "DSH_IND": "Y", + "IME_IND": "N", + "GME_IND": "Y" + }""" + + result = prompt_calls.prompt_special_case_breakout( + prompt_templates.FACILITY_ADJUSTMENT_BREAKOUT, + "Facility adjustment term", + self.filename, + ) + + # Should return dict with normalized fields + self.assertIsInstance(result, dict) + + # Check fields against format mapping + expected_values = { + "DSH_IND": "Y", + "IME_IND": "N", + "GME_IND": "Y", + } + + for field_name, expected_value in expected_values.items(): + expected_format = FIELD_FORMAT_MAPPING.get(field_name, "str") + actual_value = result.get(field_name) + + if expected_format == "str": + self.assertIsInstance( + actual_value, + str, + f"{field_name} should be str (format: {expected_format}), got {type(actual_value)}", + ) + self.assertEqual( + actual_value, + expected_value, + f"{field_name} value mismatch: expected {expected_value}, got {actual_value}", + ) + elif expected_format == "list[str]": + self.assertIsInstance( + actual_value, + list, + f"{field_name} should be list[str] (format: {expected_format}), got {type(actual_value)}", + ) + if isinstance(expected_value, str): + self.assertEqual( + actual_value, + [expected_value], + f"{field_name} value mismatch: expected [{expected_value}], got {actual_value}", + ) + else: + self.assertEqual( + actual_value, + expected_value, + f"{field_name} value mismatch: expected {expected_value}, got {actual_value}", + ) + else: + self.fail(f"Unexpected format {expected_format} for {field_name}") + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_special_case_assignment_returns_dict(self, mock_invoke): + """Test that prompt_special_case_assignment returns a dictionary.""" + # Simulate LLM returning index as list + mock_invoke.return_value = "[0]" + + special_case_dicts = [ + {"FACILITY_ADJUSTMENT_TERM": "Term1", "DSH_IND": "Y"}, + {"FACILITY_ADJUSTMENT_TERM": "Term2", "DSH_IND": "N"}, + ] + + result = prompt_calls.prompt_special_case_assignment( + self.exhibit_text, + {"SERVICE_TERM": "Service", "REIMB_TERM": "Reimb"}, + special_case_dicts, + "FACILITY_ADJUSTMENT_TERM", + self.filename, + ) + + # Should return one of the special case dicts + self.assertIsInstance(result, dict) + self.assertIn("FACILITY_ADJUSTMENT_TERM", result) + + # ==================== LOB RELATIONSHIP PROMPTS ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_lob_relationship_normalizes_to_str(self, mock_invoke): + """Test that prompt_lob_relationship normalizes to str format.""" + # Simulate LLM returning relationship as list + mock_invoke.return_value = '["Inclusive"]' + + answer_dict = { + "AARETE_DERIVED_LOB": "Medicare", + "AARETE_DERIVED_PROGRAM": "STAR", + } + result = prompt_calls.prompt_lob_relationship( + answer_dict, "AARETE_DERIVED_PROGRAM", self.exhibit_text, self.filename + ) + + # LOB_PROGRAM_RELATIONSHIP is str in format mapping + expected_format = FIELD_FORMAT_MAPPING.get("LOB_PROGRAM_RELATIONSHIP", "str") + if expected_format == "str": + self.assertIsInstance( + result, + str, + f"LOB_PROGRAM_RELATIONSHIP should be str (format: {expected_format}), got {type(result)}", + ) + self.assertIn(result, ["Inclusive", "Exclusive"]) + elif expected_format == "list[str]": + self.assertIsInstance( + result, + list, + f"LOB_PROGRAM_RELATIONSHIP should be list[str] (format: {expected_format}), got {type(result)}", + ) + + # ==================== ONE-TO-ONE PROMPTS ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_full_context_normalizes_fields(self, mock_invoke): + """Test that prompt_full_context normalizes one-to-one fields.""" + # Simulate LLM returning full context answers + mock_invoke.return_value = """{ + "PAYER_NAME": "Test Payer", + "CONTRACT_TITLE": "Test Contract", + "EFFECTIVE_DT": "2024/01/01" + }""" + + full_context_fields = FieldSet(config.FIELD_JSON_PATH, field_type="one_to_one") + + result = prompt_calls.prompt_full_context( + "Contract text here", full_context_fields, self.constants, self.filename + ) + + # Should return dict with normalized fields + self.assertIsInstance(result, dict) + + # Check fields against format mapping + for field_name in ["PAYER_NAME", "CONTRACT_TITLE", "EFFECTIVE_DT"]: + if field_name in result: + expected_format = FIELD_FORMAT_MAPPING.get(field_name, "str") + actual_value = result.get(field_name) + + if expected_format == "str": + self.assertIsInstance( + actual_value, + str, + f"{field_name} should be str (format: {expected_format}), got {type(actual_value)}", + ) + elif expected_format == "list[str]": + self.assertIsInstance( + actual_value, + list, + f"{field_name} should be list[str] (format: {expected_format}), got {type(actual_value)}", + ) + + # ==================== VALIDATION PROMPTS ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_validate_reimbursements_for_llm_returns_boolean(self, mock_invoke): + """Test that validate_reimbursements_for_llm returns a boolean.""" + # Simulate LLM returning YES + mock_invoke.return_value = '["YES"]' + + answer_dict = {"SERVICE_TERM": "Service", "REIMB_TERM": "100% of Medicare"} + result = prompt_calls.validate_reimbursements_for_llm( + answer_dict, self.filename + ) + + # Should return boolean + self.assertIsInstance(result, bool) + self.assertTrue(result) + + # Test with NO + mock_invoke.return_value = '["NO"]' + result = prompt_calls.validate_reimbursements_for_llm( + answer_dict, self.filename + ) + self.assertIsInstance(result, bool) + self.assertFalse(result) + + # ==================== EXHIBIT HELPER PROMPTS ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_exhibit_linkage_returns_string(self, mock_invoke): + """Test that prompt_exhibit_linkage returns a string.""" + # Simulate LLM returning YES/NO as list + mock_invoke.return_value = '["YES"]' + + result = prompt_calls.prompt_exhibit_linkage( + "Exhibit A", "Exhibit B", self.filename + ) + + # Should return string (extracted from list) + self.assertIsInstance(result, str) + self.assertIn(result, ["YES", "NO"]) + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_exhibit_header_returns_string(self, mock_invoke): + """Test that prompt_exhibit_header returns a string.""" + # Simulate LLM returning header as list + mock_invoke.return_value = '["Exhibit A - Services"]' + + result = prompt_calls.prompt_exhibit_header( + "Page content with Exhibit A header", self.filename + ) + + # Should return string (extracted from list) + self.assertIsInstance(result, str) + self.assertIn("Exhibit", result) + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_exhibit_title_match_returns_string(self, mock_invoke): + """Test that prompt_exhibit_title_match returns YES/NO string.""" + # Simulate LLM returning YES as list + mock_invoke.return_value = '["YES"]' + + result = prompt_calls.prompt_exhibit_title_match( + "Exhibit A", "Exhibit A - Services", self.filename + ) + + # Should return uppercase YES/NO string + self.assertIsInstance(result, str) + self.assertIn(result, ["YES", "NO"]) + + # ==================== DATE PROMPTS ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_date_fix_returns_string(self, mock_invoke): + """Test that prompt_date_fix returns a formatted date string.""" + # Simulate LLM returning date as list + mock_invoke.return_value = '["2024/01/01"]' + + result = prompt_calls.prompt_date_fix("January 1, 2024") + + # Should return string (extracted from list) + self.assertIsInstance(result, str) + self.assertIn("2024", result) + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_derived_term_date_returns_string(self, mock_invoke): + """Test that prompt_derived_term_date returns a date string.""" + # Simulate LLM returning date as list + mock_invoke.return_value = '["2024/12/31"]' + + result = prompt_calls.prompt_derived_term_date("2024/01/01", "12 months") + + # Should return string (extracted from list) + self.assertIsInstance(result, str) + self.assertIn("2024", result) + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_split_reimb_dates_normalizes_date_fields(self, mock_invoke): + """Test that prompt_split_reimb_dates normalizes date fields.""" + # Simulate LLM returning date range + mock_invoke.return_value = ( + '{"start_date": "2024/01/01", "end_date": "2024/12/31"}' + ) + + result = prompt_calls.prompt_split_reimb_dates( + "January 1, 2024 through December 31, 2024", self.filename + ) + + # Should return dict with date strings + self.assertIsInstance(result, dict) + self.assertIsInstance(result.get("start_date"), str) + self.assertIsInstance(result.get("end_date"), str) + self.assertEqual(result["start_date"], "2024/01/01") + self.assertEqual(result["end_date"], "2024/12/31") + + # ==================== LESSER-OF PROMPTS ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_lesser_of_distribution_returns_string(self, mock_invoke): + """Test that prompt_lesser_of_distribution returns a string.""" + # Simulate LLM returning updated term as list + mock_invoke.return_value = '["lesser of $100 or billed charges"]' + + result = prompt_calls.prompt_lesser_of_distribution( + "Service Term", + "Reimbursement Term", + self.exhibit_text, + "1", + [], + self.filename, + ) + + # Should return string (extracted from list) + self.assertIsInstance(result, str) + self.assertIn("lesser", result.lower()) + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_lesser_of_check_returns_dict(self, mock_invoke): + """Test that prompt_lesser_of_check returns a classification dict.""" + # Simulate LLM returning classification + mock_invoke.return_value = """{ + "service_term": "Service", + "reimb_term": "lesser of rates or charges", + "scope": "STANDALONE", + "target_exhibit": null, + "exhibit_reference": null + }""" + + result = prompt_calls.prompt_lesser_of_check( + "Service Term", "Reimbursement Term", "Exhibit A", self.filename + ) + + # Should return dict with classification + self.assertIsInstance(result, dict) + self.assertIn("scope", result) + self.assertIn("service_term", result) + self.assertIn("reimb_term", result) + + # ==================== PROVIDER PROMPTS ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_provider_info_returns_list_of_dicts(self, mock_invoke): + """Test that prompt_provider_info returns list of provider dicts.""" + # Simulate LLM returning provider info + mock_invoke.return_value = """[ + {"TIN": "123456789", "NPI": "987654321", "NAME": "Provider Name"} + ]""" + + text_dict = {"1": "Page text with provider info"} + result = prompt_calls.prompt_provider_info( + text_dict, "1", self.filename, "Test Payer" + ) + + # Should return list of dicts + self.assertIsInstance(result, list) + self.assertGreater(len(result), 0) + self.assertIsInstance(result[0], dict) + self.assertIn("TIN", result[0]) + self.assertIn("NPI", result[0]) + self.assertIn("NAME", result[0]) + + @patch("src.utils.llm_utils.invoke_claude") + def test_provider_name_match_check_returns_boolean(self, mock_invoke): + """Test that provider_name_match_check returns a boolean.""" + # Simulate LLM returning Y + mock_invoke.return_value = '["Y"]' + + result = prompt_calls.provider_name_match_check( + "Provider Name", "Provider Name", self.filename + ) + + # Should return boolean + self.assertIsInstance(result, bool) + + # Test with N + mock_invoke.return_value = '["N"]' + result = prompt_calls.provider_name_match_check( + "Provider Name", "Different Name", self.filename + ) + self.assertIsInstance(result, bool) + self.assertFalse(result) + + # ==================== EDGE CASES ==================== + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_handles_llm_reasoning_text(self, mock_invoke): + """Test that prompts handle LLM reasoning text before JSON.""" + # Simulate LLM returning reasoning text before JSON (common pattern) + mock_invoke.return_value = """Let me analyze this: + + Based on the contract text, I can see: + + {"PRODUCT": ["Product1"], "PROGRAM": "Program1"} + + This is the final answer.""" + + field_prompts = { + "PRODUCT": "What products?", + "PROGRAM": "What program?", + } + + result = prompt_calls.prompt_dynamic( + self.exhibit_text, field_prompts, self.filename + ) + + # Should extract JSON and normalize correctly + self.assertIsInstance(result, dict) + + # Check each field against format mapping + for field_name, expected_value in [ + ("PRODUCT", ["Product1"]), + ("PROGRAM", ["Program1"]), + ]: + expected_format = FIELD_FORMAT_MAPPING.get(field_name, "str") + actual_value = result.get(field_name) + + if expected_format == "list[str]": + self.assertIsInstance( + actual_value, + list, + f"{field_name} should be list[str] (format: {expected_format}), got {type(actual_value)}", + ) + self.assertEqual(actual_value, expected_value) + elif expected_format == "str": + self.assertIsInstance( + actual_value, + str, + f"{field_name} should be str (format: {expected_format}), got {type(actual_value)}", + ) + else: + self.fail(f"Unexpected format {expected_format} for {field_name}") + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_handles_empty_and_none_values(self, mock_invoke): + """Test that prompts handle empty and None values correctly.""" + # Simulate LLM returning empty/null values + mock_invoke.return_value = """{ + "PRODUCT": [], + "CONTRACT_TITLE": null, + "NETWORK": "" + }""" + + field_prompts = { + "PRODUCT": "What products?", + "CONTRACT_TITLE": "What is the contract title?", + "NETWORK": "What networks?", + } + + result = prompt_calls.prompt_dynamic( + self.exhibit_text, field_prompts, self.filename + ) + + # Check each field against format mapping + for field_name, input_value in [ + ("PRODUCT", []), + ("CONTRACT_TITLE", None), + ("NETWORK", ""), + ]: + expected_format = FIELD_FORMAT_MAPPING.get(field_name, "str") + actual_value = result.get(field_name) + + if expected_format == "list[str]": + self.assertIsInstance( + actual_value, + list, + f"{field_name} should be list[str] (format: {expected_format}), got {type(actual_value)}", + ) + self.assertEqual(actual_value, []) + elif expected_format == "str": + self.assertIsInstance( + actual_value, + str, + f"{field_name} should be str (format: {expected_format}), got {type(actual_value)}", + ) + # null/None should normalize to empty string for str fields + self.assertEqual(actual_value, "") + else: + self.fail(f"Unexpected format {expected_format} for {field_name}") + + @patch("src.utils.llm_utils.invoke_claude") + def test_prompt_dynamic_primary_handles_string_representation_of_list( + self, mock_invoke + ): + """Test that prompt_dynamic_primary handles string representation of list.""" + # LLM sometimes returns: '["Value1", "Value2"]' as a string + mock_invoke.return_value = '"["Medicare", "Medicaid"]"' + + mock_field = MagicMock() + mock_field.field_name = "LOB" + mock_field.get_prompt.return_value = "What LOB values are present?" + + result = prompt_calls.prompt_dynamic_primary( + self.exhibit_text, + mock_field, + self.constants, + self.filename, + prompt_templates.DYNAMIC_PRIMARY, + ) + + # Check expected format from mapping + expected_format = FIELD_FORMAT_MAPPING.get("LOB", "str") + if expected_format == "list[str]": + self.assertIsInstance( + result, + list, + f"LOB should be list[str] (format: {expected_format}), got {type(result)}", + ) + self.assertEqual(result, ["Medicare", "Medicaid"]) + elif expected_format == "str": + self.assertIsInstance( + result, + str, + f"LOB should be str (format: {expected_format}), got {type(result)}", + ) + else: + self.fail(f"Unexpected format {expected_format} for LOB") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/utils/formatting_utils.py b/src/utils/formatting_utils.py new file mode 100644 index 0000000..c2ee6fc --- /dev/null +++ b/src/utils/formatting_utils.py @@ -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 diff --git a/src/utils/json_utils.py b/src/utils/json_utils.py index cef4b33..dba8947 100644 --- a/src/utils/json_utils.py +++ b/src/utils/json_utils.py @@ -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]}..." - ) diff --git a/src/utils/string_utils.py b/src/utils/string_utils.py index 0b905f4..8515815 100644 --- a/src/utils/string_utils.py +++ b/src/utils/string_utils.py @@ -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