Files
doczyai-pipelines/src/pipelines/shared/postprocessing/aarete_derived.py
T
Praneel Panchigar a082b1864c Merged in dev (pull request #1016)
Dev

* Merged in bugfix/DAIP2-2823-generic-issue-fixes-one-to-one (pull request #1011)

Bugfix/DAIP2-2823 generic issue fixes one to one

* updated auto renewal ind prompt

* updated CONTRACT_AMENDMENT_NUM prompt

* Merged dev into bugfix/DAIP2-2823-generic-issue-fixes-one-to-one


Approved-by: Praneel Panchigar
Approved-by: Siddhant Medar

* Merged in feature/DAIP2-2698-phase-3-program-product-lob-mapping (pull request #1000)

Feature/DAIP2-2698 phase 3 program product lob mapping

* added missing phase 2 modifications

* added phase 3 modifications

* Fixed acronym issues

* black format fix

* Merged dev into feature/DAIP2-2698-phase-3-program-product-lob-mapping

* standardization fixes

* Merged dev into feature/DAIP2-2698-phase-3-program-product-lob-mapping

* added hyphenated suffix names fix

* black format fix

* standardization fix

* dynamic primary fix

* Merge Dev into feature/DAIP2-2698-phase-3-program-product-lob-mapping

* updated Program Product Standardization

* updated prompt instrcutions

* added documenttaion markdown file

* add Rule 5 counter-example for Plus-less sibling in PRODUCT canonical prompt

Clarify that Rule 5's abbreviation absorption does not extend to the
bare-form sibling when the anchor's expansion itself contains 'Plus'.
Addresses a row in the may18 canonical run where 'Molina Medicare
Options' (no Plus) was…

Approved-by: Siddhant Medar
2026-05-19 18:21:52 +00:00

598 lines
26 KiB
Python

import logging
import re
import pandas as pd
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.pipelines.saas.prompts import prompt_calls
from src.prompts.fieldset import FieldSet
from src.utils.crosswalk_utils import apply_crosswalk
from src.utils.formatting_utils import normalize_field_value
def _canonical_lob(val: str, valid_lobs_lower: dict[str, str]) -> str:
"""Return the canonical-cased LOB from valid_lobs (case-insensitive match).
Falls back to title case if no match found."""
return valid_lobs_lower.get(val.strip().lower(), val.strip().title())
def fill_na_from_field(results_dict, to_field, from_field, crosswalk_path):
from_str = results_dict.get(from_field)
if string_utils.is_empty(from_str):
return results_dict.get(to_field)
MAPPING_DICT = CrosswalkBuilder().from_json(crosswalk_path).mapping
to_value = apply_crosswalk(from_str, MAPPING_DICT)
return to_value if to_value else results_dict.get(to_field)
def fill_na_mapping(df: pd.DataFrame, constants: Constants) -> pd.DataFrame:
"""
Derive AARETE_DERIVED_LOB from AARETE_DERIVED_PROGRAM and AARETE_DERIVED_PRODUCT.
Uses crosswalk mapping when available; falls back to LLM when the mapping is empty.
Merges unique LOB values from both sources.
Args:
df (pd.DataFrame): DataFrame with extracted fields.
constants (Constants): Shared Constants instance (loaded once at startup).
Returns:
pd.DataFrame: DataFrame with filled/merged AARETE_DERIVED_LOB values.
"""
if df.shape[0] == 0:
return df
filename = df["FILE_NAME"].iloc[0] if "FILE_NAME" in df.columns else "unknown"
payer_name_raw = df["PAYER_NAME"].iloc[0] if "PAYER_NAME" in df.columns else ""
payer_state_raw = df["PAYER_STATE"].iloc[0] if "PAYER_STATE" in df.columns else ""
payer_name = (
str(payer_name_raw) if not string_utils.is_empty(payer_name_raw) else ""
)
payer_state = (
str(payer_state_raw) if not string_utils.is_empty(payer_state_raw) else ""
)
answer_dicts = df.to_dict(orient="records")
# Crosswalk values are the AARETE_DERIVED_LOB values - pass these to the LLM
lob_mapping_values = constants.CROSSWALK_LOB.mapping.values()
valid_lobs = sorted(
{
item
for v in lob_mapping_values
for item in (v if isinstance(v, list) else [v])
if item and item != "N/A"
}
)
# Case-insensitive lookup → canonical form (e.g. "MEDICAID" → "Medicaid")
valid_lobs_lower: dict[str, str] = {v.lower(): v for v in valid_lobs}
def _to_str_list(val) -> list:
"""Normalize a field value to a deduplicated list of non-empty strings."""
if string_utils.is_empty(val):
return []
if isinstance(val, list):
return [str(v).strip() for v in val if str(v).strip()]
if isinstance(val, str):
return [val.strip()] if val.strip() else []
return []
# --- Pre-compute LOB caches keyed by unique input tuples ---
# Collect unique keys across all rows so each distinct value is resolved once.
unique_aarete_program_keys: set[tuple] = set()
unique_program_keys: set[tuple] = set()
unique_aarete_product_keys: set[tuple] = set()
unique_product_keys: set[tuple] = set()
for answer_dict in answer_dicts:
adp = _to_str_list(answer_dict.get("AARETE_DERIVED_PROGRAM"))
if adp:
unique_aarete_program_keys.add(tuple(adp))
p = _to_str_list(answer_dict.get("PROGRAM"))
if p:
unique_program_keys.add(tuple(p))
adprod = _to_str_list(answer_dict.get("AARETE_DERIVED_PRODUCT"))
if adprod:
unique_aarete_product_keys.add(tuple(adprod))
prod = _to_str_list(answer_dict.get("PRODUCT"))
if prod:
unique_product_keys.add(tuple(prod))
# Crosswalk cache: AARETE_DERIVED_PROGRAM → LOB list
program_crosswalk_cache: dict[tuple, list] = {}
if constants.CROSSWALK_PROGRAM_LOB.mapping:
for key in unique_aarete_program_keys:
program_crosswalk_cache[key] = [
constants.CROSSWALK_PROGRAM_LOB.mapping[v]
for v in key
if v in constants.CROSSWALK_PROGRAM_LOB.mapping
]
# LLM cache: PROGRAM → LOB list (one call per unique program list)
program_llm_cache: dict[tuple, list] = {}
for key in unique_program_keys:
program_llm_cache[key] = (
prompt_calls.prompt_program_to_lob(
list(key),
valid_lobs,
filename,
payer_name=payer_name,
payer_state=payer_state,
)
or []
)
# Crosswalk cache: AARETE_DERIVED_PRODUCT → LOB list
product_crosswalk_cache: dict[tuple, list] = {}
if constants.CROSSWALK_PRODUCT_LOB.mapping:
for key in unique_aarete_product_keys:
product_crosswalk_cache[key] = [
constants.CROSSWALK_PRODUCT_LOB.mapping[v]
for v in key
if v in constants.CROSSWALK_PRODUCT_LOB.mapping
]
# LLM cache: PRODUCT → LOB list (one call per unique product list)
product_llm_cache: dict[tuple, list] = {}
for key in unique_product_keys:
product_llm_cache[key] = (
prompt_calls.prompt_product_to_lob(
list(key),
valid_lobs,
filename,
payer_name=payer_name,
payer_state=payer_state,
)
or []
)
# --- Apply cached results to each row ---
for answer_dict in answer_dicts:
all_lob_values = set()
# Get existing AARETE_DERIVED_LOB values (if any)
# Handle both list and string formats (JSON lists or pipe-delimited strings)
existing_lob_list = answer_dict.get("AARETE_DERIVED_LOB", "")
if not string_utils.is_empty(existing_lob_list):
if isinstance(existing_lob_list, list):
lob_items = existing_lob_list
elif isinstance(existing_lob_list, str):
lob_items = [existing_lob_list]
else:
lob_items = [str(existing_lob_list)]
for lob_val in lob_items:
if (
isinstance(lob_val, str)
and lob_val.strip()
and lob_val.strip() != "N/A"
):
all_lob_values.add(_canonical_lob(lob_val, valid_lobs_lower))
# Get AARETE_DERIVED_LOB from AARETE_DERIVED_PROGRAM + PROGRAM (via caches)
program_lob_values = set()
adp_key = tuple(_to_str_list(answer_dict.get("AARETE_DERIVED_PROGRAM")))
p_key = tuple(_to_str_list(answer_dict.get("PROGRAM")))
if program_crosswalk_cache:
logging.info(
f"Using crosswalk cache for AARETE_DERIVED_PROGRAM key: {adp_key}"
)
program_lob_list = program_crosswalk_cache.get(adp_key, [])
else:
logging.info(f"Using LLM cache for PROGRAM key: {p_key}")
program_lob_list = program_llm_cache.get(p_key, [])
filtered = [
_canonical_lob(v, valid_lobs_lower)
for v in program_lob_list
if isinstance(v, str) and v.strip() and v.strip() != "N/A"
]
if filtered:
program_lob_values.update(filtered)
all_lob_values.update(filtered)
# Get AARETE_DERIVED_LOB from AARETE_DERIVED_PRODUCT + PRODUCT (via caches)
product_lob_values = set()
adprod_key = tuple(_to_str_list(answer_dict.get("AARETE_DERIVED_PRODUCT")))
prod_key = tuple(_to_str_list(answer_dict.get("PRODUCT")))
if product_crosswalk_cache:
logging.info(
f"Using crosswalk cache for AARETE_DERIVED_PRODUCT key: {adprod_key}"
)
product_lob_list = product_crosswalk_cache.get(adprod_key, [])
else:
logging.info(f"Using LLM cache for PRODUCT key: {prod_key}")
product_lob_list = product_llm_cache.get(prod_key, [])
filtered = [
_canonical_lob(v, valid_lobs_lower)
for v in product_lob_list
if isinstance(v, str) and v.strip() and v.strip() != "N/A"
]
if filtered:
product_lob_values.update(filtered)
all_lob_values.update(filtered)
# Set the merged, deduplicated value
if all_lob_values:
answer_dict["AARETE_DERIVED_LOB"] = sorted(all_lob_values)
# Only set if field is empty or "N/A" (preserve LLM-determined Inclusive/Exclusive)
if program_lob_values:
existing_relationship = answer_dict.get("LOB_PROGRAM_RELATIONSHIP", "")
if string_utils.is_empty(existing_relationship):
answer_dict["LOB_PROGRAM_RELATIONSHIP"] = "Exclusive"
if product_lob_values:
existing_relationship = answer_dict.get("LOB_PRODUCT_RELATIONSHIP", "")
if string_utils.is_empty(existing_relationship):
answer_dict["LOB_PRODUCT_RELATIONSHIP"] = "Exclusive"
elif string_utils.is_empty(answer_dict.get("AARETE_DERIVED_LOB")):
# If still empty after all attempts, keep it as is (will be N/A or empty)
pass
# Handle cases where PROGRAM/PRODUCT is NA/blank
# Set relationship to "Exclusive" as default when missing and relationship is not set
if string_utils.is_empty(answer_dict.get("AARETE_DERIVED_PROGRAM")):
if string_utils.is_empty(answer_dict.get("LOB_PROGRAM_RELATIONSHIP")):
answer_dict["LOB_PROGRAM_RELATIONSHIP"] = "Exclusive"
if string_utils.is_empty(answer_dict.get("AARETE_DERIVED_PRODUCT")):
if string_utils.is_empty(answer_dict.get("LOB_PRODUCT_RELATIONSHIP")):
answer_dict["LOB_PRODUCT_RELATIONSHIP"] = "Exclusive"
return pd.DataFrame(answer_dicts)
def get_crosswalk_fields(answer_dicts: list, constants: Constants):
"""
Apply crosswalk mappings to derive target fields from source fields.
Normalizes output based on FIELD_FORMAT_MAPPING to ensure correct format
(str vs list[str]) for each target field.
"""
crosswalk_fields = FieldSet(file_path=config.FIELD_JSON_PATH, crosswalk=True)
for to_field in crosswalk_fields.fields:
to_field_name, from_field_name = to_field.field_name, to_field.base_field
# Find crosswalk
crosswalk = constants.get_constant(to_field.crosswalk)
if crosswalk:
for answer_dict in answer_dicts:
to_field_value = answer_dict.get(to_field_name)
from_field_value = answer_dict.get(from_field_name)
# If from_field_value is populated and to_field_value is not populated, perform mapping
if not string_utils.is_empty(from_field_value) and (
string_utils.is_empty(to_field_value) or "N/A" in to_field_value
):
if isinstance(from_field_value, list):
from_field_value_list = from_field_value
else:
from_field_value_list = [from_field_value]
# Map each value in the list
to_field_answer_list = []
for individual_from_field_value in from_field_value_list:
individual_from_field_value = (
individual_from_field_value.strip()
)
if individual_from_field_value in crosswalk.mapping.keys():
# Value is a key in the mapping - map it to the target value
to_field_answer_list.append(
crosswalk.mapping.get(individual_from_field_value)
)
elif individual_from_field_value in crosswalk.mapping.values():
# Value is already in the target format (e.g., "CHIP" when target is "CHIP")
# Keep it as-is for AARETE_DERIVED fields, or use reverse mapping for others
if "AARETE_DERIVED" in to_field_name:
to_field_answer_list.append(individual_from_field_value)
else:
# Reverse mapping returns dict[str, list[str]], so .get() returns a list
# We need to extend, not append, to avoid nested lists
reverse_mapping = crosswalk.create_reverse_mapping()
reverse_result = reverse_mapping.get(
individual_from_field_value
)
if reverse_result is not None:
# reverse_result is a list, extend it
to_field_answer_list.extend(reverse_result)
else:
# If not found in reverse mapping, keep original value
to_field_answer_list.append(
individual_from_field_value
)
# 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),
# not modify the source field (from_field_name)
# If from_field_name needs to be a list, that should be handled elsewhere
return answer_dicts
# ---------------------------------------------------------------------------
# Helpers shared by PROGRAM and PRODUCT canonical derivation
# ---------------------------------------------------------------------------
def _split_multi_value_separators(text: str) -> list[str]:
"""Split a PROGRAM/PRODUCT cell on all recognised list separators.
In practice these columns use three separator conventions:
* comma ``","`` — primary Dashboard separator
* slash ``"/"`` — e.g. ``"Healthy Advantage/Healthy Advantage Plus"``
* ampersand ``" & "`` — e.g. ``"HA & HA+"``
(requires surrounding whitespace to avoid splitting abbreviated compound
names such as ``"B&G"`` or ``"A&M"``)
Returns a list of stripped, non-empty parts.
"""
parts = re.split(r",|/|\s+&\s+", text)
return [p.strip() for p in parts if p.strip()]
def _collect_unique_values_from_list_column(
column: pd.Series,
) -> list[str]:
"""Collect all unique, non-empty, non-sentinel values from a PROGRAM/PRODUCT column.
In the Dashboard DataFrame (the only place this is called), ``dashboard_postprocess``
has already run and converted every list column to a comma-separated string, e.g.
``"Medicaid Managed Care, Commercial PPO"``.
This helper handles both that string form AND the original list form so it is safe
regardless of call order.
"""
_SENTINELS = {"N/A", "UNKNOWN", "NONE", "NULL"}
seen: set[str] = set()
unique: list[str] = []
for val in column:
if string_utils.is_empty(val):
continue
if isinstance(val, list):
# Pre-dashboard-postprocess form: Python list — each element may itself
# contain secondary separators (/ or &), so split each item further.
raw_items = string_utils.flatten_to_strings(val)
items = []
for raw in raw_items:
items.extend(_split_multi_value_separators(raw))
else:
# Post-dashboard-postprocess form: comma-separated string (may also
# contain / or & separators inside individual cells).
items = _split_multi_value_separators(str(val))
for item in items:
text = item.strip()
if not string_utils.is_empty(text) and text.upper() not in _SENTINELS:
if text not in seen:
seen.add(text)
unique.append(text)
return unique
def _collect_unique_payer_value_combos(
df: pd.DataFrame, value_col: str
) -> list[tuple[str, str, str]]:
"""Collect unique (payer_name, payer_state, raw_value) triples from the DataFrame.
Iterates every row, splits the value_col cell on all recognised separators,
and pairs each resulting item with the row's PAYER_NAME / PAYER_STATE.
Combinations are deduplicated while preserving insertion order.
Used by add_aarete_derived_program_canonical and add_aarete_derived_product_canonical
to build the per-entry payer-context input for the batch LLM call.
"""
_SENTINELS = {"N/A", "UNKNOWN", "NONE", "NULL"}
seen: set[tuple[str, str, str]] = set()
result: list[tuple[str, str, str]] = []
has_payer_name = "PAYER_NAME" in df.columns
has_payer_state = "PAYER_STATE" in df.columns
for _, row in df.iterrows():
payer_name = (
str(row["PAYER_NAME"]).strip()
if has_payer_name and not string_utils.is_empty(row["PAYER_NAME"])
else ""
)
payer_state = (
str(row["PAYER_STATE"]).strip()
if has_payer_state and not string_utils.is_empty(row["PAYER_STATE"])
else ""
)
val = row[value_col]
if string_utils.is_empty(val):
continue
if isinstance(val, list):
raw_items = string_utils.flatten_to_strings(val)
items: list[str] = []
for raw in raw_items:
items.extend(_split_multi_value_separators(raw))
else:
items = _split_multi_value_separators(str(val))
for item in items:
text = item.strip()
if not string_utils.is_empty(text) and text.upper() not in _SENTINELS:
combo = (payer_name, payer_state, text)
if combo not in seen:
seen.add(combo)
result.append(combo)
return result
def _all_empty_in_list_column(column: pd.Series) -> bool:
"""Return True when every cell in a list[str] column is empty / null."""
return all(string_utils.is_empty(val) for val in column)
def _map_row_list_to_canonicals(raw_val, canonical_map: dict[str, str]) -> str:
"""Map one PROGRAM/PRODUCT cell to a comma-separated string of canonical names.
Input can be a Python list (pre-dashboard-postprocess) or a comma-separated
string (post-dashboard-postprocess, which is the normal case for the Dashboard DF).
Output is always a comma-separated string to match the Dashboard DF column format.
"""
_SENTINELS = {"N/A", "UNKNOWN", "NONE", "NULL"}
if string_utils.is_empty(raw_val):
return ""
if isinstance(raw_val, list):
raw_items = string_utils.flatten_to_strings(raw_val)
items = []
for raw in raw_items:
items.extend(_split_multi_value_separators(raw))
else:
# Post-dashboard-postprocess: comma-separated string (may also contain
# / or & separators within individual cells, e.g. "HA/HA+").
items = _split_multi_value_separators(str(raw_val))
result: list[str] = []
seen: set[str] = set()
for v in items:
text = v.strip()
if string_utils.is_empty(text) or text.upper() in _SENTINELS:
continue
canonical = canonical_map.get(text, text.upper())
if canonical and canonical not in seen:
seen.add(canonical)
result.append(canonical)
elif canonical in seen:
logging.debug(
"_map_row_list_to_canonicals: dropped duplicate canonical '%s' "
"for raw='%s' (already mapped from an earlier value in this cell)",
canonical,
text,
)
return ", ".join(result)
# ---------------------------------------------------------------------------
# Public API — called from runner.py (Dashboard only)
# ---------------------------------------------------------------------------
def add_aarete_derived_program_canonical(
df: pd.DataFrame, valid_programs: list[str]
) -> pd.DataFrame:
"""Derive canonical program names from the raw PROGRAM column.
Architecture (single global LLM call):
1. Collect all unique raw PROGRAM values across the entire Dashboard DataFrame.
2. Make ONE LLM call with all unique values + payer context + valid_programs anchor.
The LLM groups semantically equivalent variants and assigns a single canonical
per group, resolving brand-prefix noise vs. meaningful qualifiers correctly.
3. Build a {original_name -> canonical_name} lookup and apply it to every row.
Trigger conditions (either is sufficient):
- ``valid_programs`` is empty (no client-supplied valid-values list), OR
- the ``AARETE_DERIVED_PROGRAM`` column is entirely empty/null for all rows.
Args:
df: Dashboard DataFrame containing at minimum a ``PROGRAM`` column.
valid_programs: ``Constants.VALID_AARETE_DERIVED_PROGRAMS`` list.
Returns:
DataFrame with ``AARETE_DERIVED_PROGRAM`` populated/overwritten with
canonical names derived from the raw PROGRAM values.
"""
df_copy = df.copy()
if "AARETE_DERIVED_PROGRAM" not in df_copy.columns:
df_copy["AARETE_DERIVED_PROGRAM"] = ""
has_valid_programs = bool(valid_programs)
adp_col_empty = _all_empty_in_list_column(df_copy["AARETE_DERIVED_PROGRAM"])
if has_valid_programs and not adp_col_empty:
logging.info(
"Skipping canonical PROGRAM derivation: valid mapping exists and "
"AARETE_DERIVED_PROGRAM is already populated."
)
return df_copy
if "PROGRAM" not in df_copy.columns:
logging.warning(
"PROGRAM column not found in DataFrame; skipping canonical program derivation."
)
return df_copy
# Step 1 — collect unique (payer_name, payer_state, raw_value) combinations
unique_combos = _collect_unique_payer_value_combos(df_copy, "PROGRAM")
if not unique_combos:
logging.info(
"No non-empty PROGRAM values found; skipping canonical program derivation."
)
return df_copy
# Step 2 — single global LLM call returns {raw: canonical} dict
canonical_map = prompt_calls.prompt_aarete_derived_program_canonical_batch(
unique_combos, valid_programs
)
# Step 4 — apply mapping to each row
df_copy["AARETE_DERIVED_PROGRAM"] = df_copy["PROGRAM"].apply(
lambda val: _map_row_list_to_canonicals(val, canonical_map)
)
return df_copy
def add_aarete_derived_product_canonical(
df: pd.DataFrame, valid_products: list[str]
) -> pd.DataFrame:
"""Derive canonical product names from the raw PRODUCT column.
Architecture (single global LLM call):
1. Collect all unique raw PRODUCT values across the entire Dashboard DataFrame.
2. Make ONE LLM call with all unique values + payer context + valid_products anchor.
The LLM groups semantically equivalent variants and assigns a single canonical
per group, resolving brand-prefix noise vs. meaningful qualifiers correctly.
3. Build a {original_name -> canonical_name} lookup and apply it to every row.
Trigger conditions (either is sufficient):
- ``valid_products`` is empty (no client-supplied valid-values list), OR
- the ``AARETE_DERIVED_PRODUCT`` column is entirely empty/null for all rows.
Args:
df: Dashboard DataFrame containing at minimum a ``PRODUCT`` column.
valid_products: ``Constants.VALID_AARETE_DERIVED_PRODUCTS`` list.
Returns:
DataFrame with ``AARETE_DERIVED_PRODUCT`` populated/overwritten with
canonical names derived from the raw PRODUCT values.
"""
df_copy = df.copy()
if "AARETE_DERIVED_PRODUCT" not in df_copy.columns:
df_copy["AARETE_DERIVED_PRODUCT"] = ""
has_valid_products = bool(valid_products)
adp_col_empty = _all_empty_in_list_column(df_copy["AARETE_DERIVED_PRODUCT"])
if has_valid_products and not adp_col_empty:
logging.info(
"Skipping canonical PRODUCT derivation: valid mapping exists and "
"AARETE_DERIVED_PRODUCT is already populated."
)
return df_copy
if "PRODUCT" not in df_copy.columns:
logging.warning(
"PRODUCT column not found in DataFrame; skipping canonical product derivation."
)
return df_copy
# Step 1 — collect unique (payer_name, payer_state, raw_value) combinations
unique_combos = _collect_unique_payer_value_combos(df_copy, "PRODUCT")
if not unique_combos:
logging.info(
"No non-empty PRODUCT values found; skipping canonical product derivation."
)
return df_copy
# Step 2 — single global LLM call returns {raw: canonical} dict
canonical_map = prompt_calls.prompt_aarete_derived_product_canonical_batch(
unique_combos, valid_products
)
# Step 4 — apply mapping to each row
df_copy["AARETE_DERIVED_PRODUCT"] = df_copy["PRODUCT"].apply(
lambda val: _map_row_list_to_canonicals(val, canonical_map)
)
return df_copy