Merged in bugfix/generic_dynamic_jan26 (pull request #859)

Bugfix/generic dynamic jan26

* Refactor: Extract dynamic primary metrics logic to separate module

- Created src/testbed/dynamic_primary_metrics.py with all dynamic primary field analysis logic
- Moved tally-based analysis functions to new module for better modularity
- Updated testbed_utils.py to import and use functions from dynamic_primary_metrics
- Simplified testbed_metrics_dynamic_only.py to use new module
- Removed unused analyze_dynamic_primary_fields function (400+ lines)
- Added field filtering in match_rows to handle missing columns gracefully
- Minimal changes to testbed_utils.py (~100 lines vs 845 before)

* feat: Add debug print blocks and prompt changes for dynamic primary fields in 1:1 processing

This commit adds prompt changes for when we pass dynamic primary to 1:1  and comprehensive debug logging for dynamic primary fields when they are escalated to 1:1 processing via Hybrid Smart Chunking (HSC) or Full Context methods.

Changes:
- Added debug print blocks in hybrid_smart_chunking_funcs.py to log:
  * Retrieval question, context chunks, and final prompt for HSC processing
  * Raw LLM output (reasoning + answer) for dynamic primary fields
- Added debug print blocks in prompt_calls.py to log:
  * Contract context, final prompt for Full Context processing
  * Raw LLM output (reasoning + answer) for dynamic primary fields
- Added FULL_CONTEXT_DYNAMIC_PRIMARY_INSTRUCTION() to prompt_templates.py:
  * Provides specific guidance for dynamic primary fields in full context
  * Emphasizes extracting values only if they refer to entire contract
  * Includes pipe-delimited formatting instructions
- Updated dynamic_fun…
* Ran black, for formatting

* Refine dynamic primary field prompts and add Duals auto-detection

- Enhanced DYNAMIC_ASSIGNMENT_INSTRUCTION with structural boundary rules
- Added exhibit header and subsection context hierarchy for LOB assignment
- Implemented Duals auto-detection in dynamic_primary discovery (Medicare+Medicaid -> Duals)
- Added Duals value formatting instruction to prevent oversimplification
- Commented out DUAL_LOB_CHECK for performance testing
- Removed verbose debug print blocks from HSC and Full Context processing
- Added focused debug print for dynamic assignment raw LLM output

* Remove debug print blocks from pipeline files

- Removed all debug print blocks from file_processing.py (8 blocks)
- Removed all debug print blocks from dynamic_funcs.py (8 blocks)
- Removed debug print block from prompt_calls.py (dynamic assignment)
- DUAL_LOB_CHECK remains commented as requested
- Total: 188 lines of debug code removed

* Merge remote-tracking branch 'origin/main' into bugfix/generic_dynamic_jan26

* Ran black for CI

* Remove update_lob_for_duals

* Merge branch 'DEV' into bugfix/generic_dynamic_jan26


Approved-by: Katon Minhas
This commit is contained in:
Sha Brown
2026-01-30 23:43:48 +00:00
committed by Katon Minhas
parent 89515ca5cc
commit 1bc9e6aaf3
11 changed files with 723 additions and 127 deletions
@@ -376,6 +376,7 @@ def prompt_full_context(
],
questions=prompt_questions,
)
try:
claude_answer_raw = llm_utils.invoke_claude(
full_context_prompt,
@@ -385,6 +386,7 @@ def prompt_full_context(
cache=True,
instruction=prompt_templates.ONE_TO_ONE_MULTI_FIELD_INSTRUCTION(),
)
full_context_answers_dict = string_utils.universal_json_load(claude_answer_raw)
except Exception as e:
logging.error(f"Error processing high accuracy fields: {str(e)}")
@@ -52,6 +52,32 @@ def dynamic_primary(
)
# If there is at least ONE Non-N/A answers in the Exhibit
if not string_utils.is_empty(exhibit_text_answer):
# Special handling for LOB: If both Medicare and Medicaid are detected, add Duals
if field.field_name == "LOB":
# Parse the discovered values (pipe-delimited or comma-separated)
values_list = [
val.strip()
for val in exhibit_text_answer.replace(",", "|").split("|")
if val.strip()
]
values_lower = [val.lower() for val in values_list]
# Check if both Medicare and Medicaid are present (case-insensitive)
has_medicare = any("medicare" in val for val in values_lower)
has_medicaid = any("medicaid" in val for val in values_lower)
# If both are present and Duals is not already in the list, add it
if has_medicare and has_medicaid:
if "duals" not in values_lower:
exhibit_text_answer = (
exhibit_text_answer + " | Duals"
if exhibit_text_answer
else "Duals"
)
logging.debug(
f"Added 'Duals' to LOB valid values because both Medicare and Medicaid were detected"
)
field.update_valid_values(exhibit_text_answer + " | N/A")
dynamic_reimbursement_fields.add_field(field)
dynamic_primary_fields.remove_field(field.field_name)
@@ -213,8 +239,7 @@ def add_one_to_one_field(
# Special Handling for Program, Product, and Network:
# If any LOB value has been found, skip PROGRAM, PRODUCT, and NETWORK
# Only search for these fields when NO LOB has been found
# NOTE: Check raw LOB field, not AARETE_DERIVED_LOB, because AARETE_DERIVED_LOB
# is populated later via crosswalk and may be derived from PROGRAM/PRODUCT
if field_to_add.field_name in ["PROGRAM", "PRODUCT", "NETWORK"]:
lob_values = [answer_dict.get("LOB", "") for answer_dict in answer_dicts]
unique_lobs = set([lob for lob in lob_values if not string_utils.is_empty(lob)])
@@ -227,6 +252,15 @@ def add_one_to_one_field(
+ prompt_templates.FULL_CONTEXT_CLAIM_TYPES_ADDITIONAL_INSTRUCTION()
)
# Special instructions for dynamic primary fields when passed to 1:1
# Add specific template-language guidance, then append general full context instructions
if field_to_add.field_name in ["LOB", "PRODUCT", "PROGRAM", "NETWORK"]:
field_to_add.prompt = (
field_to_add.prompt
+ prompt_templates.FULL_CONTEXT_DYNAMIC_PRIMARY_INSTRUCTION()
)
# Apply general full context instructions to all fields
field_to_add.prompt = (
field_to_add.prompt
+ prompt_templates.FULL_CONTEXT_ADDITIONAL_INSTRUCTIONS(field_to_add.allow_na)
@@ -286,5 +320,8 @@ def get_dynamic_one_to_one_fields(
one_to_one_fields = add_one_to_one_field(
one_to_one_fields, field_to_add, answer_dicts, constants
)
else:
# Field found in some rows - keep in 1:N
pass
return one_to_one_fields
@@ -789,9 +789,6 @@ def one_to_n_cleaning(
################################ Fill NA Mapping ################################
all_exhibit_rows = aarete_derived.fill_na_mapping(all_exhibit_rows)
################################ Update LOB for Duals ################################
all_exhibit_rows = postprocessing_funcs.update_lob_for_duals(all_exhibit_rows)
################################ Split REIMB_DATES ################################
all_exhibit_rows = split_reimb_dates(all_exhibit_rows, filename)
@@ -526,45 +526,6 @@ def add_aarete_derived_product(df):
return df
def update_lob_for_duals(answer_dicts):
final_answer_dicts = []
# Iterate through each row in the dataframe
for answer_dict in answer_dicts:
# Get SERVICE_TERM value
service_term = answer_dict.get("SERVICE_TERM", "").lower()
# Check if the SERVICE_TERM contains "Medicare" and "Medicaid" - case insensitive
# AND check that there's no explicit PROGRAM or PRODUCT that would override this logic
if (
"medicare" in service_term
and "medicaid" in service_term
and string_utils.is_empty(answer_dict.get("AARETE_DERIVED_PROGRAM"))
and string_utils.is_empty(answer_dict.get("AARETE_DERIVED_PRODUCT"))
):
prompt = prompt_templates.DUAL_LOB_CHECK(service_term)
logging.debug(f"Prompt for dual LOB check: {prompt}")
claude_answer_raw = llm_utils.invoke_claude(
prompt,
model_id="legacy_sonnet",
filename="",
max_tokens=200,
cache=True,
instruction=prompt_templates.DUAL_LOB_CHECK_INSTRUCTION(),
)
logging.debug(f"Claude response for dual LOB check: {claude_answer_raw}")
claude_answer_extracted = string_utils.extract_text_from_delimiters(
claude_answer_raw, Delimiter.PIPE
)
if (
claude_answer_extracted.lower() == "neither"
or claude_answer_extracted.lower() == "n/a"
): # If neither, take original AARETE_DERIVED_LOB
pass
else:
answer_dict["AARETE_DERIVED_LOB"] = claude_answer_extracted
final_answer_dicts.append(answer_dict)
return final_answer_dicts
def fill_empty_reimb_pct_rate(df):
"""
Fill 'REIMB_PCT_RATE' based on reimbursement method:
+55 -21
View File
@@ -1,3 +1,5 @@
from typing import Optional
import src.config as config
from src.prompts.fieldset import FieldSet
from src.constants.constants import Constants
@@ -359,15 +361,44 @@ You will be shown a Reimbursement Term, and the section of a contract from which
Closely examine the contract text, find the Reimbursement Term, and decide which field value or values apply to the Reimbursement Term.
CRITICAL CONSIDERATIONS:
1. Look for what field the Service actually *applies to*. This is not the same as the Schedule used as a basis for calculation. For example, many LOBs use Medicaid as a basis for their calculation. The presence of 'Medicaid' as a rate does not on it's own guarantee that the Reimbursement Term is 'Medicaid'.
1. EXHIBIT HEADER CONTEXT - Pay close attention to exhibit headers (e.g., "MEDICARE PRODUCT ATTACHMENT") that appear at the beginning of the exhibit section. These headers indicate the scope and context for ALL reimbursement terms within that exhibit. If an exhibit header clearly specifies a line of business (e.g., "MEDICARE PRODUCT ATTACHMENT"), assign values consistent with that header (e.g., "Medicare" for LOB) for all terms in that exhibit. The exhibit header value should be ADDITIVE - it should be included as one of multiple values when other sources (service term, reimbursement term, or subsection context) also provide relevant values. For example, if the exhibit header indicates "Medicare" and the service term or subsection context indicates "Duals", the final assignment should be "Medicare|Duals" (combining both values). The exhibit header value is always included and does not override other valid values.
2. USE THE SERVICE_TERM AS PRIMARY CONTEXT: The Service Term shows which programs are mentioned together. If the Service Term explicitly lists specific programs (e.g., "STAR, CHIP HMO, CHIP PERINATE, and STAR+PLUS"), those are the programs that apply to reimbursement terms in that section. Do NOT assign programs that are mentioned in completely separate sections of the contract.
2. SUBSECTION CONTEXT - Examine the exhibit text for the subsection header, introductory paragraph, or section introduction that is CLOSEST to the reimbursement term.
3. LOOK FOR PROXIMITY: Programs must be explicitly mentioned in the SAME sentence or paragraph as the Reimbursement Term. If a reimbursement term appears in a section that lists specific programs (e.g., "STAR, CHIP HMO, CHIP PERINATE, and STAR+PLUS: Covered Services..."), only assign those programs, NOT programs mentioned in separate sections (e.g., "Medicare Advantage (Molina Medicare Options) and MA-SNP...").
RELATIONSHIP HIERARCHY:
- The exhibit header (instruction #1) is ALWAYS additive - it is included in the final assignment.
- The subsection context is ADDITIVE with respect to the exhibit header (combines with it).
- The subsection context OVERRIDES conflicting information in the reimbursement term or service term.
- The reimbursement term (instruction #3) takes LEAST precedence when exhibit header and subsection context provide clear answers.
4. AVOID CROSS-CONTAMINATION: If the Service Term mentions Medicaid programs (STAR, CHIP, CHIPP, STAR+PLUS, etc.), do NOT assign Medicare programs (MA, MASNP) unless they are explicitly mentioned together in the same context. Similarly, if the Service Term mentions Medicare programs, do NOT assign Medicaid programs unless explicitly mentioned together.
STRUCTURAL BOUNDARY RULE - FOLLOW THESE STEPS:
1. Identify the structural element containing the reimbursement term (e.g., "Table 1", "Table 2").
2. Trace backwards from that element to find the MOST RECENT section marker (e.g., "4.", "3.", "Section A", numbered items like "1.", "2.", "3.").
3. If you find a section marker, that creates a HARD BOUNDARY - subsection context from BEFORE that marker is COMPLETELY INVALIDATED and must be ignored, even if it mentioned the table.
4. Only consider subsection context that appears AFTER the most recent section marker and BEFORE the structural element.
5. If no section marker is found, then use the closest subsection context before the structural element.
Section markers (numbered items like "1.", "2.", "3.", "4.") create hard boundaries. Example: If "4. Allowable Charges...for any Medicare inpatient admission" appears before "Table 2", you MUST use ONLY context from section 4. Completely ignore subsection context from earlier sections (like "For Covered Services rendered to Covered Person who are eligible for Medicare and enrolled in a Medicare Plan that may include coverage for both Medicare and Medicaid") even if that earlier text mentioned "Table 2" - the section 4 marker invalidates all earlier context.
5. "ALL PROGRAMS" AMBIGUITY - CRITICAL: If you see phrases like "for all Health Plan products & programs" in a table or section, this phrase means "all programs in THIS SECTION", NOT "all programs in the entire document".
When a relevant subsection context is found WITHIN THE SAME structural boundary with extremely clear wording about eligibility or program coverage, it should:
- ADD to the exhibit header value (e.g., exhibit header "Medicare" + subsection "both Medicare and Medicaid" = "Medicare|Duals")
- OVERRIDE conflicting information in the service term or reimbursement term (e.g., if subsection says "Duals" but service term says "Medicaid only", assign "Duals" based on subsection)
3. REIMBURSEMENT TERM CONTEXT - Examine the Reimbursement Term itself for context about what value should be assigned to the field. This is a broad instruction to consider the reimbursement term as a primary source of information. However, if the exhibit header (instruction #1) and nearest subsection context (instruction #2) provide clear answers, the reimbursement term context takes LEAST precedence. The reimbursement term context should be considered in addition to the context provided by the exhibit header and subsection context, but should not override them when they are clear and explicit.
4. Look for what field the Service actually *applies to*. This is not the same as the Schedule used as a basis for calculation. For example, many LOBs use Medicaid or Medicare as a basis for their calculation. The presence of 'Medicaid' or 'Medicare' as a rate does not on it's own guarantee that the Reimbursement Term is 'Medicaid' or 'Medicare'.
5. DUALS VALUE FORMATTING: If the answer appears to be "Medicare|Duals" or "Medicaid|Duals", do NOT simplify to just "Duals" - the pipe-delimited format adds important context. Only simplify to "Duals" alone when it is obvious that Duals alone is sufficient and no additional context is needed.
[PROGRAM FIELD SPECIFIC INSTRUCTIONS - Instructions 6-9 below are PRIMARILY for PROGRAM field assignment, though they may loosely apply to other fields. For LOB field assignment, prioritize instructions 1-5 above these instructions.]
6. USE THE SERVICE_TERM AS CONTEXT: The Service Term shows which programs are mentioned together. If the Service Term explicitly lists specific programs (e.g., "STAR, CHIP HMO, CHIP PERINATE, and STAR+PLUS"), those are the programs that apply to reimbursement terms in that section. Do NOT assign programs that are mentioned in completely separate sections of the contract.
7. LOOK FOR PROXIMITY: Programs must be explicitly mentioned in the SAME sentence or paragraph as the Reimbursement Term. If a reimbursement term appears in a section that lists specific programs (e.g., "STAR, CHIP HMO, CHIP PERINATE, and STAR+PLUS: Covered Services..."), only assign those programs, NOT programs mentioned in separate sections (e.g., "Medicare Advantage (Molina Medicare Options) and MA-SNP...").
8. AVOID CROSS-CONTAMINATION: If the Service Term mentions Medicaid programs (STAR, CHIP, CHIPP, STAR+PLUS, etc.), do NOT assign Medicare programs (MA, MASNP) unless they are explicitly mentioned together in the same context. Similarly, if the Service Term mentions Medicare programs, do NOT assign Medicaid programs unless explicitly mentioned together.
9. "ALL PROGRAMS" AMBIGUITY - CRITICAL: If you see phrases like "for all Health Plan products & programs" in a table or section, this phrase means "all programs in THIS SECTION", NOT "all programs in the entire document".
- If the table appears AFTER a section that says "STAR, CHIP HMO, CHIP PERINATE, and STAR+PLUS" and BEFORE a section that says "Medicare Advantage", the table applies ONLY to the Medicaid programs (STAR, CHIP, CHIPP, STAR+PLUS) mentioned in that section.
- If the table appears AFTER a section that says "Medicare Advantage", the table applies ONLY to the Medicare programs mentioned in that section.
- NEVER assign programs from separate sections just because you see "all programs" - always determine which section the reimbursement term belongs to based on its position in the document."""
@@ -1253,6 +1284,25 @@ def FULL_CONTEXT_PAYER_NAME_ADDITIONAL_INSTRUCTION():
return " Additionally check the contract text for the payer name or payer organization name or payer entity or name of organization issuing the health plan/policy and Choose the most appropriate answer based on the context."
def FULL_CONTEXT_DYNAMIC_PRIMARY_INSTRUCTION():
"""Additional instructions for dynamic primary fields (LOB, PRODUCT, PROGRAM, NETWORK) when passed to 1:1 processing.
These instructions emphasize that values should only be extracted if they explicitly
refer to the entire contract and all terms, and to ignore template language or general
definitions that don't specifically apply to this contract.
"""
return (
" CRITICAL: Only extract values if they explicitly refer to the ENTIRE contract "
"and apply to ALL terms and services covered by this specific contract. "
"IGNORE any mentions that are part of template language, general definitions, "
"or boilerplate text that describes general capabilities but does not specifically "
"apply to this contract. If the value is only mentioned in definitions, examples, "
"or general descriptions without explicit application to this contract's terms, "
"return 'N/A'. Only extract values that are clearly stated as applying to this "
"specific contract and all its terms."
)
def CHECK_PROVIDER_NAME_MATCH_INSTRUCTION() -> str:
"""Static instruction for CHECK_PROVIDER_NAME_MATCH prompt caching.
Contains all matching rules and critical exclusions.
@@ -1627,22 +1677,6 @@ Service: {service_term}
Reimbursement Term: {reimb_term.replace('"', "'")}"""
def DUAL_LOB_CHECK(service_term):
return f"""Examine the given Service and determine if the Service applies to Medicare, Medicaid, or Both.
If the Service is only for Medicaid, return 'Medicaid'
If the Service is only for Medicare, return 'Medicare'
If the Service is for both Medicare and Medicaid, return 'Duals'
If the service is for neither Medicare nor Medicaid, return 'neither'
If it is unclear, write 'N/A'.
Here is the Service:
{service_term}
Explain your answer in one or two sentences, but put your final answer in |pipes|.
"""
def EFFECTIVE_DATE_FIX_PROMPT() -> str:
return """Extract the effective date of the contract or contract effective date or Effective Date of Agreement or Effective Date of Amendment or the amendment effective date from the given image data. ALSO LOOK FOR dates in phrases that semantically indicate an effective date (may or may not be explictly labelled as effective date), even if they do not exactly match the phrases above.\nExamples include but ARE NOT LIMITED TO:\nPhrases indicating when an contract or amendment begins or takes effect, Phrases specifying the start date of the contract or amendment, Phrases defining when the contract or amendment becomes operational, or Any phrase indicating contract or amendment creation or execution date.\nIt is possible that the contract will specify that the effective date is derived from elsewhere in the contract. Give the date in a JSON FORMAT with AARETE_DERIVED_EFFECTIVE_DATE as the key and effective date value in YYYY/MM/DD format as the value.
Rules:
+412
View File
@@ -0,0 +1,412 @@
"""Dynamic Primary Fields Metrics Analysis.
This module provides tally-based, order-independent analysis for dynamic primary fields:
- AARETE_DERIVED_LOB
- AARETE_DERIVED_PROGRAM
- AARETE_DERIVED_NETWORK
- AARETE_DERIVED_PRODUCT
"""
from collections import Counter
import pandas as pd
import src.config as config
import src.utils.string_utils as string_utils
def analyze_dynamic_primary_fields_tally_based(
testbed_file: pd.DataFrame,
results_file: pd.DataFrame,
fields: list[str],
):
"""Analyze dynamic primary fields using a tally/count-based approach.
This approach is order-independent and focuses on value counts rather than
specific row matching. It provides insights into:
1. Correct values: Counts match between testbed and results
2. Missing values: Testbed has more occurrences of a value
3. Extra values: Results have more occurrences of a value
Args:
testbed_file: DataFrame containing ground truth for a single file
results_file: DataFrame containing predictions for a single file
fields: List of dynamic primary field names to analyze
Returns:
dict: Dictionary containing detailed metrics for each field
"""
analysis_results = {}
def expand_pipe_delimited_values(values):
"""Expand pipe-delimited values into individual values for counting."""
expanded = []
for v in values:
if pd.notna(v) and not string_utils.is_empty(v):
# Split by pipe and add each value
for part in str(v).strip().upper().split("|"):
part = part.strip()
if part and part != "EMPTY":
expanded.append(part)
return expanded
def normalize_value(value):
"""Normalize a value for comparison."""
if pd.isna(value) or string_utils.is_empty(value):
return None
return str(value).strip().upper()
for field in fields:
# Get all values from testbed and results
testbed_values = [normalize_value(v) for v in testbed_file[field]]
results_values = [normalize_value(v) for v in results_file[field]]
# Expand pipe-delimited values (e.g., "Duals|Medicare" -> ["Duals", "Medicare"])
testbed_expanded = expand_pipe_delimited_values(testbed_values)
results_expanded = expand_pipe_delimited_values(results_values)
# Count occurrences
testbed_counts = Counter(testbed_expanded)
results_counts = Counter(results_expanded)
# Calculate metrics
all_values = set(testbed_counts.keys()) | set(results_counts.keys())
tp = 0 # Values with correct counts
missing_details = (
[]
) # Values in testbed but not in results (or fewer in results)
extra_details = [] # Values in results but not in testbed (or more in results)
for value in all_values:
testbed_count = testbed_counts.get(value, 0)
results_count = results_counts.get(value, 0)
# TP is the minimum count (the matching portion)
matching_count = min(testbed_count, results_count)
if matching_count > 0:
tp += matching_count
# Missing: testbed has more than results
if testbed_count > results_count:
missing_count = testbed_count - results_count
missing_details.append(
(value, testbed_count, results_count, missing_count)
)
# Extra: results have more than testbed
if results_count > testbed_count:
extra_count = results_count - testbed_count
extra_details.append((value, testbed_count, results_count, extra_count))
# Calculate totals
missing_count = sum(diff for _, _, _, diff in missing_details)
extra_count = sum(diff for _, _, _, diff in extra_details)
analysis_results[field] = {
"tp": tp,
"missing_values": missing_details,
"extra_values": extra_details,
"missing_count": missing_count,
"extra_count": extra_count,
"testbed_counts": dict(testbed_counts),
"results_counts": dict(results_counts),
}
return analysis_results
def get_dynamic_primary_analysis(testbed, results, fields):
"""Analyze dynamic primary fields across all files using tally-based approach.
Args:
testbed: DataFrame containing ground truth
results: DataFrame containing predictions
fields: List of dynamic primary field names to analyze
Returns:
dict: Dictionary with "summary", "details", and "overall" DataFrames
"""
print("\n*************** Dynamic Primary Fields Analysis ****************")
dynamic_primary_results = []
dynamic_primary_summary = []
for file in testbed["FILE_NAME"].unique():
testbed_file = testbed[testbed["FILE_NAME"] == file]
results_file = results[results["FILE_NAME"] == file]
# Ensure there are rows in both dataframes
if testbed_file.shape[0] == 0 or results_file.shape[0] == 0:
print(f"No rows in either labels or predictions for file: {file}")
continue
# Use tally-based analysis for dynamic primary (order-independent)
file_analysis = analyze_dynamic_primary_fields_tally_based(
testbed_file, results_file, fields
)
# Create detailed results for this file
file_result = {
"Filename": file,
"testbed_row_count": len(testbed_file),
"results_row_count": len(results_file),
}
file_summary = {
"Filename": file,
"testbed_row_count": len(testbed_file),
"results_row_count": len(results_file),
}
for field in fields:
field_data = file_analysis[field]
tp = field_data["tp"]
missing = field_data["missing_count"]
extra = field_data["extra_count"]
mismatch = missing + extra
total = tp + missing + extra
# Calculate error score: (Missing × 3 + Extra × 1) / (TP + Missing + Extra)
# Missing is weighted 3× more than Extra since missing values are more problematic
if total > 0:
weighted_errors = missing * 3 + extra * 1
error_score = min(weighted_errors / total, 1.0)
else:
error_score = 0.0
# Store counts
file_result[f"{field}_tp"] = tp
file_result[f"{field}_missing_count"] = missing
file_result[f"{field}_extra_count"] = extra
file_result[f"{field}_mismatch_count"] = mismatch
file_result[f"{field}_error_score"] = error_score
# Store testbed and results counts in dictionary format
testbed_counts_str = str(field_data["testbed_counts"])
results_counts_str = str(field_data["results_counts"])
file_result[f"{field}_testbed_counts"] = testbed_counts_str
file_result[f"{field}_results_counts"] = results_counts_str
# Store detailed lists (format for readability in Excel)
missing_str = "\n".join(
[
f"{val}: Testbed={t_count}, Results={r_count}, Missing={diff}"
for val, t_count, r_count, diff in field_data["missing_values"]
]
)
file_result[f"{field}_missing_values"] = missing_str if missing_str else ""
extra_str = "\n".join(
[
f"{val}: Testbed={t_count}, Results={r_count}, Extra={diff}"
for val, t_count, r_count, diff in field_data["extra_values"]
]
)
file_result[f"{field}_extra_values"] = extra_str if extra_str else ""
# Summary counts for aggregation
file_summary[f"{field}_tp"] = tp
file_summary[f"{field}_missing"] = missing
file_summary[f"{field}_extra"] = extra
file_summary[f"{field}_mismatch"] = mismatch
file_summary[f"{field}_error_score"] = error_score
dynamic_primary_results.append(file_result)
dynamic_primary_summary.append(file_summary)
# Create overall summary across all files
overall_summary = {}
for field in fields:
total_tp = sum(r[f"{field}_tp"] for r in dynamic_primary_summary)
total_missing = sum(
r.get(f"{field}_missing", 0) for r in dynamic_primary_summary
)
total_extra = sum(r.get(f"{field}_extra", 0) for r in dynamic_primary_summary)
total_mismatch = total_missing + total_extra
total_errors = total_mismatch
total_cases = total_tp + total_errors
# Precision: Of all value occurrences, how many had correct counts?
precision = (
total_tp / (total_tp + total_missing + total_extra)
if (total_tp + total_missing + total_extra) > 0
else 0
)
# Recall: Of all testbed value occurrences, how many were correctly extracted?
recall = (
total_tp / (total_tp + total_missing)
if (total_tp + total_missing) > 0
else 0
)
f1 = (
(2 * precision * recall) / (precision + recall)
if (precision + recall) > 0
else 0
)
# Overall error score (weighted average across files)
total_weighted_errors = total_missing * 3 + total_extra * 1
overall_error_score = (
total_weighted_errors / total_cases if total_cases > 0 else 0
)
overall_summary[field] = {
"tp": total_tp,
"missing": total_missing,
"extra": total_extra,
"mismatch": total_mismatch,
"error_score": overall_error_score,
"prec": precision,
"rec": recall,
"f1": f1,
}
overall_df = pd.DataFrame(overall_summary).T
overall_df.index.name = "Field"
summary_df = pd.DataFrame(dynamic_primary_summary)
details_df = pd.DataFrame(dynamic_primary_results)
return {
"summary": summary_df,
"details": details_df,
"overall": overall_df,
}
def export_dynamic_primary_to_excel(output_file, dynamic_primary_metrics):
"""Export dynamic primary fields analysis to Excel with separate sheets per field.
Args:
output_file: Path to output Excel file
dynamic_primary_metrics: Dictionary with "summary", "details", and "overall" DataFrames
"""
summary_df = dynamic_primary_metrics["summary"]
details_df = dynamic_primary_metrics["details"]
overall_df = dynamic_primary_metrics["overall"]
dynamic_fields = [
"AARETE_DERIVED_LOB",
"AARETE_DERIVED_PROGRAM",
"AARETE_DERIVED_NETWORK",
"AARETE_DERIVED_PRODUCT",
]
with pd.ExcelWriter(output_file, engine="openpyxl") as writer:
# Create separate sheets for each dynamic primary field
for field in dynamic_fields:
if f"{field}_tp" not in summary_df.columns:
continue
# Create field-specific sheet
field_df = pd.DataFrame(
{
"Filename": summary_df["Filename"],
"TP": summary_df[f"{field}_tp"],
"Missing": summary_df[f"{field}_missing"],
"Extra": summary_df[f"{field}_extra"],
"Mismatch": summary_df[f"{field}_mismatch"],
"Error_Score": summary_df[f"{field}_error_score"],
"Testbed_Counts": details_df[f"{field}_testbed_counts"],
"Results_Counts": details_df[f"{field}_results_counts"],
}
)
# Sort by Error_Score descending (most problematic first)
field_df = field_df.sort_values("Error_Score", ascending=False)
# Use a clean sheet name
sheet_name = field.replace("AARETE_DERIVED_", "").replace("_", " ").title()
field_df.to_excel(writer, sheet_name=sheet_name, index=False)
# Overall summary sheet with aggregated statistics across all files
overall_df.to_excel(writer, sheet_name="Overall Summary", index=True)
# Detailed summary sheet (all files, all fields)
summary_df.to_excel(writer, sheet_name="Summary", index=False)
# Details sheet (all files, all fields with counts)
details_df.to_excel(writer, sheet_name="Details", index=False)
# Auto-adjust column widths
for sheet_name in writer.sheets:
worksheet = writer.sheets[sheet_name]
for column in worksheet.columns:
max_length = 0
column = [cell for cell in column]
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
worksheet.column_dimensions[column[0].column_letter].width = (
adjusted_width
)
def run_dynamic_primary_analysis_only(testbed_path, results_path, output_path=None):
"""Run dynamic primary fields analysis only (standalone function).
Args:
testbed_path: Path to testbed CSV file
results_path: Path to results CSV file
output_path: Optional path for output Excel file. If None, uses default naming.
Returns:
dict: Dictionary with "summary", "details", and "overall" DataFrames
"""
import src.testbed.testbed_utils as testbed_utils
# Read files
print(f"Loading testbed from: {testbed_path}")
testbed = pd.read_csv(testbed_path)
print(f"Testbed loaded: {testbed.shape[0]} rows, {testbed.shape[1]} columns")
print(f"Loading results from: {results_path}")
results = pd.read_csv(results_path)
print(f"Results loaded: {results.shape[0]} rows, {results.shape[1]} columns")
# Preprocess
testbed = testbed_utils.testbed_preprocess(testbed)
results = testbed_utils.testbed_preprocess(results)
testbed, results = testbed_utils.filter_file_names(testbed, results)
print(f"\nAfter filtering: {len(testbed['FILE_NAME'].unique())} files in common")
# Define fields to analyze
dynamic_primary_fields = [
"AARETE_DERIVED_LOB",
"AARETE_DERIVED_PROGRAM",
"AARETE_DERIVED_NETWORK",
"AARETE_DERIVED_PRODUCT",
]
# Filter to only fields that exist in both dataframes
fields_to_analyze = [
f
for f in dynamic_primary_fields
if f in testbed.columns and f in results.columns
]
print(f"Analyzing {len(fields_to_analyze)} fields: {fields_to_analyze}")
# Run the analysis
metrics = get_dynamic_primary_analysis(testbed, results, fields_to_analyze)
# Export to Excel if output path provided
if output_path:
print("\nExporting results to Excel...")
export_dynamic_primary_to_excel(output_path, metrics)
print(f"Results exported to: {output_path}")
elif output_path is None:
# Use default naming
output_path = f"data/testbed/{config.TODAY}-testbed-metrics-dynamic-only.xlsx"
print("\nExporting results to Excel...")
export_dynamic_primary_to_excel(output_path, metrics)
print(f"Results exported to: {output_path}")
return metrics
+12 -6
View File
@@ -9,10 +9,16 @@ warnings.filterwarnings("ignore")
########################## READ AND PREPROCESS ##########################
# Read files
testbed = pd.read_csv("Doczy-Testbed-Official.csv") # This is the testbed
results = pd.read_csv(
"inv-test-20260122-generic-RESULTS.csv"
) # This is the doczy output
testbed_path = "data/testbed/Doczy-Testbed-Official.csv"
results_path = "data/testbed/inv-test-20260122-generic-RESULTS-CLEAN.csv"
print(f"Loading testbed from: {testbed_path}")
testbed = pd.read_csv(testbed_path) # This is the testbed
print(f"Testbed loaded: {testbed.shape[0]} rows, {testbed.shape[1]} columns")
print(f"Loading results from: {results_path}")
results = pd.read_csv(results_path) # This is the doczy output
print(f"Results loaded: {results.shape[0]} rows, {results.shape[1]} columns")
########################## Preprocess ##########################
testbed = testbed_utils.testbed_preprocess(testbed)
@@ -42,8 +48,8 @@ provider_results, provider_metrics_df = (
########################## Export to Excel ##########################
print("\nExporting results to Excel...")
# Create Excel writer object
output_file = f"{config.TODAY}-testbed-metrics.xlsx"
# Create Excel writer object - save in testbed folder
output_file = f"data/testbed/{config.TODAY}-testbed-metrics.xlsx"
testbed_utils.export_to_excel(
output_file,
testbed,
@@ -0,0 +1,30 @@
"""Run dynamic primary fields analysis only.
This script analyzes only the 4 dynamic primary fields:
- AARETE_DERIVED_LOB
- AARETE_DERIVED_PROGRAM
- AARETE_DERIVED_NETWORK
- AARETE_DERIVED_PRODUCT
"""
import warnings
import src.testbed.dynamic_primary_metrics as dynamic_primary_metrics
warnings.filterwarnings("ignore")
if __name__ == "__main__":
# File paths
testbed_path = "data/testbed/Doczy-Testbed-Official.csv"
results_path = "data/testbed/inv-test-20260122-generic-RESULTS-CLEAN.csv"
print("\n" + "=" * 80)
print("ANALYZING DYNAMIC PRIMARY FIELDS ONLY")
print("=" * 80)
# Run the analysis (includes preprocessing, analysis, and export)
metrics = dynamic_primary_metrics.run_dynamic_primary_analysis_only(
testbed_path, results_path
)
print("\nAnalysis complete!")
+2 -2
View File
@@ -2,8 +2,8 @@
# function `testbed_postprocess` from the `testbed_utils` module (similar to
# postprocess.postprocess() but skipping some steps)
# Run this from the fieldExtraction directory:
# poetry run python -m src.testbed.testbed_postprocess
# Run this from the project root:
# uv run python -m src.testbed.testbed_postprocess
#### CONFIG ####
testbed_filename = "src/testbed/v6_DoczyAI_all_test_bed_LIVE_5.6.25.xlsx"
+171 -49
View File
@@ -12,6 +12,13 @@ from rapidfuzz import fuzz
from src.pipelines.shared.postprocessing import postprocessing_funcs
from src.core.fieldset import FieldSet
# Import dynamic primary metrics functions
from src.testbed.dynamic_primary_metrics import (
analyze_dynamic_primary_fields_tally_based,
export_dynamic_primary_to_excel,
get_dynamic_primary_analysis,
)
def filter_file_names(testbed, results):
# Ensure FILE_NAME values match between testbed and results
@@ -579,6 +586,19 @@ def match_rows(fields, labels_df, predictions_df, N):
return False
# Filter fields to only include those that exist in both dataframes
original_fields = fields
fields = [
f for f in fields if f in labels_df.columns and f in predictions_df.columns
]
if not fields:
# Return empty confusion matrix for all original fields
return {
field_name: {"tp": 0, "fp": 0, "fn": 0, "unmatched_labels": []}
for field_name in original_fields
}
confusion_matrix = {
field_name: {"tp": 0, "fp": 0, "unmatched_labels": []} for field_name in fields
}
@@ -1294,6 +1314,22 @@ def get_one_to_one_analysis(testbed, results):
return metrics_df, comparison_df
def get_dynamic_primary_analysis_only(testbed, results, fields):
"""Analyze only dynamic primary fields using tally-based approach.
This is a wrapper that calls the function from dynamic_primary_metrics module.
Args:
testbed: DataFrame containing ground truth
results: DataFrame containing predictions
fields: List of dynamic primary field names to analyze
Returns:
dict: Dictionary with "summary", "details", and "overall" DataFrames
"""
return get_dynamic_primary_analysis(testbed, results, fields)
def get_one_to_n_analysis(testbed, results):
print("\n*************** One-to-N Stats ****************")
@@ -1333,48 +1369,88 @@ def get_one_to_n_analysis(testbed, results):
}
one_to_n_metrics = {}
for field_type, fields in one_to_n_fields.items():
N = len(fields)
accuracies = []
# Filter fields to only include those that exist in both dataframes
fields = [f for f in fields if f in testbed.columns and f in results.columns]
for file in testbed["FILE_NAME"].unique():
testbed_file = testbed[testbed["FILE_NAME"] == file]
results_file = results[results["FILE_NAME"] == file]
# Ensure there are rows in both dataframes
if testbed_file.shape[0] == 0 or results_file.shape[0] == 0:
print(f"No rows in either labels or predictions for file: {file}")
continue
confusion_matrix = match_rows(fields, testbed_file, results_file, N)
confusion_matrix["Filename"] = file
accuracies.append(confusion_matrix)
precision_df, recall_df, overall = calculate_precision_recall(accuracies)
one_to_n_metrics[field_type] = {
"precision": precision_df,
"recall": recall_df,
"overall": overall,
}
print(f"\nOverall Metrics: {field_type}")
print(
overall.to_string(
index=True,
float_format=lambda x: (
"{:.2f}".format(x) if pd.notnull(x) else "Not found"
),
justify="left",
col_space={
"tp": 15,
"fp": 15,
"fn": 15,
"prec": 15,
"rec": 15,
"f1": 15,
},
# Skip this field type if no fields are available
if not fields:
print(
f"\nSkipping {field_type}: No matching fields found in both testbed and results"
)
continue
# Special handling for dynamic_primary fields - use tally-based analysis
if field_type == "dynamic_primary":
# Use the imported function from dynamic_primary_metrics module
dynamic_primary_metrics_result = get_dynamic_primary_analysis(
testbed, results, fields
)
one_to_n_metrics[field_type] = dynamic_primary_metrics_result
print(f"\nOverall Metrics: {field_type}")
print(
dynamic_primary_metrics_result["overall"].to_string(
index=True,
float_format=lambda x: (
"{:.2f}".format(x) if pd.notnull(x) else "Not found"
),
justify="left",
col_space={
"tp": 15,
"missing": 15,
"extra": 15,
"mismatch": 15,
"error_score": 15,
"prec": 15,
"rec": 15,
"f1": 15,
},
)
)
else:
# Standard handling for other field types
N = len(fields)
accuracies = []
for file in testbed["FILE_NAME"].unique():
testbed_file = testbed[testbed["FILE_NAME"] == file]
results_file = results[results["FILE_NAME"] == file]
# Ensure there are rows in both dataframes
if testbed_file.shape[0] == 0 or results_file.shape[0] == 0:
print(f"No rows in either labels or predictions for file: {file}")
continue
confusion_matrix = match_rows(fields, testbed_file, results_file, N)
confusion_matrix["Filename"] = file
accuracies.append(confusion_matrix)
precision_df, recall_df, overall = calculate_precision_recall(accuracies)
one_to_n_metrics[field_type] = {
"precision": precision_df,
"recall": recall_df,
"overall": overall,
}
print(f"\nOverall Metrics: {field_type}")
print(
overall.to_string(
index=True,
float_format=lambda x: (
"{:.2f}".format(x) if pd.notnull(x) else "Not found"
),
justify="left",
col_space={
"tp": 15,
"fp": 15,
"fn": 15,
"prec": 15,
"rec": 15,
"f1": 15,
},
)
)
)
return one_to_n_metrics
@@ -1428,16 +1504,62 @@ def export_to_excel(
# One-to-N Results
for field_type, metrics in one_to_n_metrics.items():
precision_df = metrics["precision"]
recall_df = metrics["recall"]
overall = metrics["overall"]
# Special handling for dynamic_primary - create separate sheets per field
if field_type == "dynamic_primary":
summary_df = metrics["summary"]
details_df = metrics["details"]
overall = metrics["overall"]
# Add the field type to the sheet names
precision_df.to_excel(
writer, sheet_name=f"{field_type} Precision", index=False
)
recall_df.to_excel(writer, sheet_name=f"{field_type} Recall", index=False)
overall.to_excel(writer, sheet_name=f"{field_type} Overall")
# Create separate sheets for each dynamic primary field
dynamic_fields = [
"AARETE_DERIVED_LOB",
"AARETE_DERIVED_PROGRAM",
"AARETE_DERIVED_NETWORK",
"AARETE_DERIVED_PRODUCT",
]
for field in dynamic_fields:
if f"{field}_tp" not in summary_df.columns:
continue
field_df = pd.DataFrame(
{
"Filename": summary_df["Filename"],
"TP": summary_df[f"{field}_tp"],
"Missing": summary_df[f"{field}_missing"],
"Extra": summary_df[f"{field}_extra"],
"Mismatch": summary_df[f"{field}_mismatch"],
"Error_Score": summary_df[f"{field}_error_score"],
"Testbed_Counts": details_df[f"{field}_testbed_counts"],
"Results_Counts": details_df[f"{field}_results_counts"],
}
)
field_df = field_df.sort_values("Error_Score", ascending=False)
sheet_name = (
field.replace("AARETE_DERIVED_", "").replace("_", " ").title()
)
field_df.to_excel(writer, sheet_name=sheet_name, index=False)
summary_df.to_excel(
writer, sheet_name=f"{field_type} Summary", index=False
)
details_df.to_excel(
writer, sheet_name=f"{field_type} Details", index=False
)
overall.to_excel(writer, sheet_name=f"{field_type} Overall")
else:
precision_df = metrics["precision"]
recall_df = metrics["recall"]
overall = metrics["overall"]
# Add the field type to the sheet names
precision_df.to_excel(
writer, sheet_name=f"{field_type} Precision", index=False
)
recall_df.to_excel(
writer, sheet_name=f"{field_type} Recall", index=False
)
overall.to_excel(writer, sheet_name=f"{field_type} Overall")
# Reimbursement Primary Results
reimb_primary_df.to_excel(
-5
View File
@@ -659,9 +659,6 @@ class TestOneToNFuncs(unittest.TestCase):
@patch(
"src.pipelines.shared.extraction.one_to_n_funcs.aarete_derived.fill_na_mapping"
)
@patch(
"src.pipelines.shared.extraction.one_to_n_funcs.postprocessing_funcs.update_lob_for_duals"
)
@patch("src.pipelines.shared.extraction.one_to_n_funcs.split_reimb_dates")
def test_one_to_n_cleaning_full_pipeline(
self,
@@ -677,7 +674,6 @@ class TestOneToNFuncs(unittest.TestCase):
mock_crosswalk.return_value = input_data
mock_get_lob.return_value = input_data
mock_fill_na.return_value = input_data
mock_update_duals.return_value = input_data
mock_split_dates.return_value = input_data
result = one_to_n_funcs.one_to_n_cleaning(
@@ -688,7 +684,6 @@ class TestOneToNFuncs(unittest.TestCase):
mock_crosswalk.assert_called_once()
mock_get_lob.assert_called_once()
mock_fill_na.assert_called_once()
mock_update_duals.assert_called_once()
mock_split_dates.assert_called_once()
self.assertEqual(result, input_data)