Merged in feature/multithreading (pull request #828)
Feature/multithreading * fixes * Merge main into feature/multithreading Resolved conflicts: - Kept timing instrumentation in file_processing.py - Kept new 3-step Exhibit-based approach for one-to-n processing - Maintained parallelization improvements (20 workers) Changes include: - Timing utils integration for performance monitoring - Increased max_workers from 5 to 20 across all components - Parallelized code_breakout and grouper_breakout - Fixed tin_npi_funcs function call parameters * Fix max_workers error for empty documents - Add check to skip parallel processing when no pages exist - Use min(len(all_page_tasks), 20) to prevent max_workers=0 - Handles edge case of documents with no exhibits or pages * Fix max_workers=0 errors in one_to_n_funcs - Add checks before all ThreadPoolExecutor creations - Prevents errors when processing empty lists: - carveout_and_special_case - breakout - special_case_breakout - filter_services_without_reimbursements - run_lob_relationship - Ensures executor only created when there are items to process * Reduce code processing parallelism to prevent API throttling Lower max_workers from 20 to 10 for code_breakout and grouper_breakout to prevent overwhelming Bedrock API with concurrent requests * fixed * Merged main into feature/multithreading * Move documentation into folder * Fix logging statements * Merge branch 'main' into feature/multithreading * Refactor for clarity * Update previous exhibit passing logic * properly simplify exhibits * Merged main into feature/multithreading * update conditional for None * exhibit multithreading changed * exhibit multithreading changed * Merge remote-tracking branch 'origin/main' into feature/multithreading * synced with main * Parallelize dynamic assignment, refactor HSC field worker, add timing/exhibit unit tests, tidy imports/ignore helpers * analyze_regression.py edited online with Bitbucket * count_pages.py edited online with Bitbucket * compare_regressed_with_baseline.py edited online with Bitbucket * simple_testbed_compare.py edited online with Bitbucket * run_testbed_metrics_regressed.py edited online with Bitbucket Approved-by: Katon Minhas
This commit is contained in:
committed by
Katon Minhas
parent
69f23653a1
commit
8c9060e425
+4
-1
@@ -18,7 +18,7 @@ dist/
|
||||
|
||||
# Package files
|
||||
*.jar
|
||||
|
||||
*.png
|
||||
# Maven
|
||||
target/
|
||||
dist/
|
||||
@@ -103,6 +103,9 @@ new/
|
||||
# Embeddings
|
||||
fieldExtraction/embeddings/
|
||||
|
||||
# Output reports
|
||||
fieldExtraction/output_reports/
|
||||
|
||||
# PRD Documents (local only, living documents)
|
||||
*.prd
|
||||
*prd.md
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
# Field Extraction Visual Flow Guide
|
||||
|
||||
## Complete System Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ START: main.py │
|
||||
│ │
|
||||
│ 1. Load configuration (batch_id, fields, max_workers) │
|
||||
│ 2. Initialize batch tracker │
|
||||
│ 3. Read input contracts (from local folder or S3) │
|
||||
│ 4. Filter contracts with "reimbursement" for B processing │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Split Processing Paths │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓ ↓
|
||||
╔═══════════════════════╗ ╔═══════════════════════╗
|
||||
║ AC PROCESSING ║ ║ B PROCESSING ║
|
||||
║ (Contract Fields) ║ ║ (Reimbursement) ║
|
||||
╚═══════════════════════╝ ╚═══════════════════════╝
|
||||
↓ ↓
|
||||
[See AC Flow Diagram] [See B Flow Diagram]
|
||||
↓ ↓
|
||||
╔═══════════════════════╗ ╔═══════════════════════╗
|
||||
║ AC Output CSV ║ ║ B Output CSV ║
|
||||
╚═══════════════════════╝ ╚═══════════════════════╝
|
||||
↓ ↓
|
||||
└───────────┬───────────────┘
|
||||
↓
|
||||
┌───────────────────────────────┐
|
||||
│ Consolidation & QA/QC │
|
||||
│ - Merge AC + B data │
|
||||
│ - Quality checks │
|
||||
│ - Generate reports │
|
||||
└───────────────────────────────┘
|
||||
↓
|
||||
┌───────────────────────────────┐
|
||||
│ Final Outputs │
|
||||
│ - ABC combined CSV │
|
||||
│ - QA/QC Excel report │
|
||||
└───────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AC Processing Detailed Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ AC PROCESSING START │
|
||||
│ (file_processing.py: run_ac_prompts) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 1: PREPROCESSING (preprocess.py) │
|
||||
│ │
|
||||
│ Input: Raw contract text │
|
||||
│ │
|
||||
│ Actions: │
|
||||
│ ├─ Clean text (remove symbols, fix newlines) │
|
||||
│ ├─ Split into pages │
|
||||
│ └─ Create smart chunks based on keywords │
|
||||
│ │
|
||||
│ Output: text_dict (pages), ac_chunks (focused text per field) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 2: REGEX EXTRACTION │
|
||||
│ │
|
||||
│ Extract using patterns: │
|
||||
│ ├─ TIN (Tax ID): XX-XXXXXXX format │
|
||||
│ ├─ NPI (Provider ID): 10-digit number │
|
||||
│ └─ Other IRS names from specific pages │
|
||||
│ │
|
||||
│ Output: Partial ac_answers_dict │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 3: SMART CHUNKED PROMPTS (smart_chunking_funcs.py) │
|
||||
│ │
|
||||
│ For each field group with keyword matches: │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ Example: CONTRACT_EFFECTIVE_DT │ │
|
||||
│ ├─────────────────────────────────────────────────┤ │
|
||||
│ │ 1. Keywords found: "effective date", "execution"│ │
|
||||
│ │ 2. Chunk contains: Pages 1-3 │ │
|
||||
│ │ 3. Build prompt with focused question │ │
|
||||
│ │ 4. Send to Claude AI │ │
|
||||
│ │ 5. Parse response: "2024-01-15" │ │
|
||||
│ │ 6. Store in ac_answers_dict │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Repeat for ~40 field groups │
|
||||
│ │
|
||||
│ Output: ac_answers_dict with most fields filled │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 4: FULL CONTEXT PROMPTS │
|
||||
│ │
|
||||
│ For fields with no keyword matches: │
|
||||
│ ├─ Use entire contract as context │
|
||||
│ ├─ More expensive but comprehensive │
|
||||
│ └─ Ensures rare fields aren't missed │
|
||||
│ │
|
||||
│ Output: Remaining fields filled │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 5: CONDITIONAL PROMPTS │
|
||||
│ │
|
||||
│ Context-based derivations: │
|
||||
│ ├─ Use existing answers to inform new questions │
|
||||
│ └─ Example: If effective date is N/A, check signature date │
|
||||
│ │
|
||||
│ Output: Final ac_answers_dict │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 6: POSTPROCESSING (postprocess.py: ac_postprocess) │
|
||||
│ │
|
||||
│ Cleanup and standardization: │
|
||||
│ ├─ Convert dates to YYYY-MM-DD format │
|
||||
│ ├─ Clean TIN/NPI formatting │
|
||||
│ ├─ Derive indicator fields (Y/N from text) │
|
||||
│ ├─ Add metadata (filename, page count, parent code) │
|
||||
│ ├─ Remove null/invalid values │
|
||||
│ ├─ Rename columns to user-friendly names │
|
||||
│ └─ Reorder columns │
|
||||
│ │
|
||||
│ Output: Clean DataFrame │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 7: WRITE OUTPUT │
|
||||
│ │
|
||||
│ Save to: {batch_id}-AC.csv │
|
||||
│ Format: One row per contract, ~45 columns │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## B Processing Detailed Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ B PROCESSING START │
|
||||
│ (file_processing.py: run_b_prompts) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 1: PREPROCESSING (preprocess.py) │
|
||||
│ │
|
||||
│ Input: Raw contract text │
|
||||
│ │
|
||||
│ Actions: │
|
||||
│ ├─ Clean text (symbols, newlines) │
|
||||
│ ├─ Split into pages │
|
||||
│ ├─ Identify exhibit pages (headers like "Exhibit", "Attachment") │
|
||||
│ ├─ Chunk consecutive pages together │
|
||||
│ └─ Align and format tables │
|
||||
│ │
|
||||
│ Output: text_dict with chunked pages and aligned tables │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 2: BOTTOM-UP PRIMARY (one_to_n_funcs.py) │
|
||||
│ │
|
||||
│ Process each page/chunk with reimbursement info: │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ For Page/Chunk: │ │
|
||||
│ │ │ │
|
||||
│ │ Prompt (prompts.py: BOTTOM_UP_PRIMARY): │ │
|
||||
│ │ "Extract EVERY reimbursement term on this page" │ │
|
||||
│ │ │ │
|
||||
│ │ Input Page Example: │ │
|
||||
│ │ ┌────────────────────────────────────────┐ │ │
|
||||
│ │ │ PHYSICIAN SERVICES │ │ │
|
||||
│ │ │ Office Visits: 110% Medicare │ │ │
|
||||
│ │ │ Surgery: 120% Medicare │ │ │
|
||||
│ │ │ Lab Tests: $25.00 per test │ │ │
|
||||
│ │ └────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ AI Extraction: │ │
|
||||
│ │ [ │ │
|
||||
│ │ { │ │
|
||||
│ │ "FULL_SERVICE": "Office Visits", │ │
|
||||
│ │ "FULL_METHODOLOGY": "110% Medicare", │ │
|
||||
│ │ "PROV_TYPE": "Professional" │ │
|
||||
│ │ }, │ │
|
||||
│ │ { │ │
|
||||
│ │ "FULL_SERVICE": "Surgery", │ │
|
||||
│ │ "FULL_METHODOLOGY": "120% Medicare", │ │
|
||||
│ │ "PROV_TYPE": "Professional" │ │
|
||||
│ │ }, │ │
|
||||
│ │ { │ │
|
||||
│ │ "FULL_SERVICE": "Lab Tests", │ │
|
||||
│ │ "FULL_METHODOLOGY": "$25.00 per test", │ │
|
||||
│ │ "PROV_TYPE": "Ancillary" │ │
|
||||
│ │ } │ │
|
||||
│ │ ] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Output: List of dictionaries (one per service found) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 3: BOTTOM-UP SECONDARY (one_to_n_funcs.py) │
|
||||
│ │
|
||||
│ For each extracted service, break down methodology: │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Methodology Breakout │ │
|
||||
│ │ │ │
|
||||
│ │ Prompt (prompts.py: BOTTOM_UP_METHODOLOGY_BREAKOUT): │ │
|
||||
│ │ "Parse this methodology into structured fields" │ │
|
||||
│ │ │ │
|
||||
│ │ Input: "Lesser of billed charges or 110% Medicare" │ │
|
||||
│ │ │ │
|
||||
│ │ AI Breakdown: │ │
|
||||
│ │ { │ │
|
||||
│ │ "LESSER": "Y", │ │
|
||||
│ │ "RATE_STANDARD": "110% Medicare", │ │
|
||||
│ │ "RATE_SHORT": "1.1", │ │
|
||||
│ │ "FLAT_FEE_STANDARD": "N/A", │ │
|
||||
│ │ "LESSER_RATE": "100% of BC", │ │
|
||||
│ │ "NOT_TO_EXCEED": "N/A" │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Escalator Detection │ │
|
||||
│ │ │ │
|
||||
│ │ Prompt (prompts.py: BOTTOM_UP_ESCALATOR): │ │
|
||||
│ │ "Does this rate increase over time?" │ │
|
||||
│ │ │ │
|
||||
│ │ Output: │ │
|
||||
│ │ { │ │
|
||||
│ │ "RATE_ESCALATOR_IND": "Y", │ │
|
||||
│ │ "RATE_ESCALATOR_DT": "2025-01-01" │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Output: Enhanced list with structured rate information │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 4: TOP-DOWN EXTRACTION (one_to_n_funcs.py) │
|
||||
│ │
|
||||
│ Extract page-level context that applies to all services: │
|
||||
│ │
|
||||
│ Prompt (prompts.py: TOP_DOWN_PRIMARY): │
|
||||
│ ├─ EXHIBIT: Full exhibit/attachment name │
|
||||
│ ├─ CONTRACT_LOB: Line of Business (Medicare, Medicaid, etc.) │
|
||||
│ ├─ DEFAULT_TERM: What happens if no rate specified │
|
||||
│ ├─ CDM_IND: Chargemaster neutralization? │
|
||||
│ └─ ADD_ON_REIMBURSEMENT_LANGUAGE: Extra payment clauses │
|
||||
│ │
|
||||
│ Example Output: │
|
||||
│ { │
|
||||
│ "EXHIBIT": "Attachment C: Medicare Advantage", │
|
||||
│ "CONTRACT_LOB": "Medicare Advantage", │
|
||||
│ "DEFAULT_TERM": "100% of Medicare Fee Schedule", │
|
||||
│ "CDM_IND": "N" │
|
||||
│ } │
|
||||
│ │
|
||||
│ This context is merged with ALL services on that page │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 5: CONDITIONAL PROMPTS (conditional_funcs.py) │
|
||||
│ │
|
||||
│ Fill in additional service-level details: │
|
||||
│ │
|
||||
│ ├─ IP/OP: Inpatient or Outpatient? │
|
||||
│ │ Prompt: "Is this service IP or OP?" │
|
||||
│ │ Uses keywords like "inpatient", "outpatient" │
|
||||
│ │ │
|
||||
│ ├─ PROV_TYPE_2: More specific provider category │
|
||||
│ │ Prompt: "What specific provider type?" │
|
||||
│ │ Options: PCP, Specialist, ER, Clinic, etc. │
|
||||
│ │ │
|
||||
│ └─ LOB_CHECK: Assign single LOB to each service │
|
||||
│ Prompt: "Which LOB does this service fall under?" │
|
||||
│ Narrows from page-level to service-level │
|
||||
│ │
|
||||
│ Output: Fully enriched service list │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 6: POSTPROCESSING (postprocess.py: b_postprocess) │
|
||||
│ │
|
||||
│ Cleanup and standardization: │
|
||||
│ ├─ Add metadata (filename, parent code, page count) │
|
||||
│ ├─ Flag single code, multiple rates (SCMR) │
|
||||
│ ├─ Filter out add-on services │
|
||||
│ ├─ Clean methodology text │
|
||||
│ ├─ Standardize provider types │
|
||||
│ ├─ Clean lesser of rates │
|
||||
│ ├─ Standardize Line of Business values │
|
||||
│ ├─ Fix Y/N fields │
|
||||
│ ├─ Rename columns │
|
||||
│ └─ Reorder columns │
|
||||
│ │
|
||||
│ Output: Clean DataFrame │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 7: WRITE OUTPUT │
|
||||
│ │
|
||||
│ Save to: {batch_id}-B.csv │
|
||||
│ Format: Multiple rows per contract (one per service), ~35 columns │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prompt Flow for Key Extractions
|
||||
|
||||
### Contract Effective Date Extraction
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ CONTRACT_EFFECTIVE_DT EXTRACTION │
|
||||
└────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Smart Chunking │
|
||||
│ Keywords: "effective date", "execution date" │
|
||||
│ Result: Pages 1-3 contain keywords │
|
||||
└────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Build Prompt (prompts.py: get_effective_date_prompt) │
|
||||
│ │
|
||||
│ Context: [Page 1-3 text] │
|
||||
│ │
|
||||
│ Instruction: │
|
||||
│ "Extract ONLY the effective date of the contract │
|
||||
│ Follow these rules: │
|
||||
│ 1. Look for 'Effective Date:' label │
|
||||
│ 2. Don't use signature date │
|
||||
│ 3. Return in YYYY-MM-DD format or N/A │
|
||||
│ 4. Enclose answer in |pipes|" │
|
||||
│ │
|
||||
│ Example Text: │
|
||||
│ "This Agreement is effective as of January 15, │
|
||||
│ 2024 ('Effective Date')..." │
|
||||
└────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Send to Claude AI │
|
||||
│ Model: Claude 3.5 Sonnet │
|
||||
└────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ AI Response │
|
||||
│ "...reviewing the contract text... │
|
||||
│ Answer: |2024-01-15|" │
|
||||
└────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Parse Response │
|
||||
│ - Extract text between |pipes| │
|
||||
│ - Result: "2024-01-15" │
|
||||
└────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Validate & Convert │
|
||||
│ - Check format is YYYY-MM-DD │
|
||||
│ - If invalid, set to N/A │
|
||||
└────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Store in Output │
|
||||
│ CONTRACT_EFFECTIVE_DT = "2024-01-15" │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Reimbursement Methodology Extraction
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ REIMBURSEMENT METHODOLOGY EXTRACTION │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Page Text (Example) │
|
||||
│ ──────────────────────────────────────────────────────────── │
|
||||
│ COMPENSATION SCHEDULE - PROFESSIONAL SERVICES │
|
||||
│ │
|
||||
│ CCA shall reimburse Provider for Covered Services rendered │
|
||||
│ to Covered Persons the lesser of: │
|
||||
│ (i) Allowable Charges; or │
|
||||
│ (ii) The rates specified below: │
|
||||
│ │
|
||||
│ Service Category | Contracted Rate │
|
||||
│ ──────────────────────────────────────────── │
|
||||
│ Office Visits | 110% of Medicare Fee Schedule │
|
||||
│ Preventive Care | 100% of Medicare Fee Schedule │
|
||||
│ Specialist Consults | 125% of Medicare Fee Schedule │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ STAGE 1: Bottom-Up Primary │
|
||||
│ Prompt: BOTTOM_UP_PRIMARY │
|
||||
│ │
|
||||
│ Instruction: "Extract EVERY reimbursement term" │
|
||||
│ │
|
||||
│ Key Rules: │
|
||||
│ - Include complete "lesser of" statements │
|
||||
│ - Capture table headers for context │
|
||||
│ - One entry per service │
|
||||
│ - Include provider type │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ AI Response (JSON) │
|
||||
│ [ │
|
||||
│ { │
|
||||
│ "FULL_SERVICE": "Office Visits", │
|
||||
│ "SUBHEADER": "N/A", │
|
||||
│ "FULL_METHODOLOGY": "Lesser of: (i) Allowable Charges; │
|
||||
│ or (ii) The rates specified below: Service Category | │
|
||||
│ Contracted Rate\nOffice Visits | 110% of Medicare │
|
||||
│ Fee Schedule", │
|
||||
│ "PROV_TYPE": "Professional" │
|
||||
│ }, │
|
||||
│ { │
|
||||
│ "FULL_SERVICE": "Preventive Care", │
|
||||
│ "SUBHEADER": "N/A", │
|
||||
│ "FULL_METHODOLOGY": "Lesser of: (i) Allowable Charges; │
|
||||
│ or (ii) The rates specified below: Service Category | │
|
||||
│ Contracted Rate\nPreventive Care | 100% of Medicare │
|
||||
│ Fee Schedule", │
|
||||
│ "PROV_TYPE": "Professional" │
|
||||
│ }, │
|
||||
│ { │
|
||||
│ "FULL_SERVICE": "Specialist Consults", │
|
||||
│ "SUBHEADER": "N/A", │
|
||||
│ "FULL_METHODOLOGY": "Lesser of: (i) Allowable Charges; │
|
||||
│ or (ii) The rates specified below: Service Category | │
|
||||
│ Contracted Rate\nSpecialist Consults | 125% of │
|
||||
│ Medicare Fee Schedule", │
|
||||
│ "PROV_TYPE": "Professional" │
|
||||
│ } │
|
||||
│ ] │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ STAGE 2: Bottom-Up Secondary (Methodology Breakout) │
|
||||
│ │
|
||||
│ For each service, parse methodology: │
|
||||
│ │
|
||||
│ Input (Office Visits): │
|
||||
│ "Lesser of: (i) Allowable Charges; or (ii) 110% of Medicare │
|
||||
│ Fee Schedule" │
|
||||
│ │
|
||||
│ Prompt: BOTTOM_UP_METHODOLOGY_BREAKOUT │
|
||||
│ "Break this methodology into structured fields" │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ AI Response (Office Visits) │
|
||||
│ { │
|
||||
│ "LESSER": "Y", │
|
||||
│ "RATE_STANDARD": "110% Medicare", │
|
||||
│ "RATE_SHORT": "1.1", │
|
||||
│ "FLAT_FEE_STANDARD": "N/A", │
|
||||
│ "LESSER_RATE": "100% of AC", // AC = Allowable Charges │
|
||||
│ "NOT_TO_EXCEED": "N/A" │
|
||||
│ } │
|
||||
│ │
|
||||
│ Repeat for Preventive Care → RATE_STANDARD: "100% Medicare" │
|
||||
│ Repeat for Specialist → RATE_STANDARD: "125% Medicare" │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ STAGE 3: Top-Down Context │
|
||||
│ │
|
||||
│ Extract page-level info: │
|
||||
│ { │
|
||||
│ "EXHIBIT": "COMPENSATION SCHEDULE - PROFESSIONAL SERVICES", │
|
||||
│ "CONTRACT_LOB": "Commercial", │
|
||||
│ "DEFAULT_TERM": "N/A", │
|
||||
│ "CDM_IND": "N" │
|
||||
│ } │
|
||||
│ │
|
||||
│ Merge with each service │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ FINAL OUTPUT (Office Visits row) │
|
||||
│ ──────────────────────────────────────────────────────────── │
|
||||
│ Contract Name: "contract_123.pdf" │
|
||||
│ Exhibit: "COMPENSATION SCHEDULE - PROFESSIONAL SERVICES" │
|
||||
│ Line of Business: "Commercial" │
|
||||
│ Full Service: "Office Visits" │
|
||||
│ Provider Type: "Professional" │
|
||||
│ Reimbursement Methodology: "Lesser of: (i) Allowable │
|
||||
│ Charges; or (ii) 110% of Medicare Fee Schedule" │
|
||||
│ Rate Standard: "110% Medicare" │
|
||||
│ Rate Short: "1.1" │
|
||||
│ Flat Fee: "N/A" │
|
||||
│ Lesser Of: "Y" │
|
||||
│ Lesser Of Rate: "100% of AC" │
|
||||
│ Not to Exceed: "N/A" │
|
||||
│ ... (additional columns) │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Flow
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ File Processing Attempt │
|
||||
└────────────────────────────────────────────┘
|
||||
↓
|
||||
┌──────────────────────┐
|
||||
│ Success? │
|
||||
└──────────────────────┘
|
||||
↓ Yes ↓ No
|
||||
┌─────────┐ ┌─────────────────────┐
|
||||
│ Log │ │ Capture Error │
|
||||
│ Success │ │ - Error message │
|
||||
│ │ │ - Stack trace │
|
||||
│ Update │ │ - Timestamp │
|
||||
│ Tracking│ └─────────────────────┘
|
||||
│ │ ↓
|
||||
│ Continue│ ┌─────────────────────┐
|
||||
│ to Next │ │ Log to Tracking │
|
||||
└─────────┘ │ Status: Failed │
|
||||
│ Error: [details] │
|
||||
└─────────────────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ Mark for Self-Repair│
|
||||
└─────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────┐
|
||||
│ After All Files Processed │
|
||||
└────────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────────┐
|
||||
│ Self-Repair Mode │
|
||||
│ - Identify failed files │
|
||||
│ - Retry processing │
|
||||
│ - Consolidate outputs │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Summary
|
||||
|
||||
```
|
||||
Raw Contract Text
|
||||
↓
|
||||
┌─────────────┐
|
||||
│ Preprocessing│
|
||||
│ - Clean │
|
||||
│ - Split │
|
||||
│ - Chunk │
|
||||
└─────────────┘
|
||||
↓
|
||||
┌─────────────┐ ┌──────────────┐
|
||||
│ AC Path │ │ B Path │
|
||||
│ │ │ │
|
||||
│ Smart Chunk │ │ Bottom-Up │
|
||||
│ Prompts │ │ Primary │
|
||||
│ ↓ │ │ ↓ │
|
||||
│ Full Context│ │ Bottom-Up │
|
||||
│ Prompts │ │ Secondary │
|
||||
│ ↓ │ │ ↓ │
|
||||
│ Conditional │ │ Top-Down │
|
||||
│ Prompts │ │ ↓ │
|
||||
│ ↓ │ │ Conditional │
|
||||
│ Postprocess │ │ ↓ │
|
||||
│ │ │ Postprocess │
|
||||
└─────────────┘ └──────────────┘
|
||||
↓ ↓
|
||||
┌─────────────┐ ┌──────────────┐
|
||||
│ AC CSV │ │ B CSV │
|
||||
│ (1 row) │ │ (many rows) │
|
||||
└─────────────┘ └──────────────┘
|
||||
↓ ↓
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌───────────────┐
|
||||
│ Consolidation │
|
||||
│ - Merge AC+B │
|
||||
│ - QA/QC │
|
||||
└───────────────┘
|
||||
↓
|
||||
┌───────────────┐
|
||||
│ Final Outputs │
|
||||
│ - ABC CSV │
|
||||
│ - QA Report │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Processing Visualization
|
||||
|
||||
```
|
||||
Main Process
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
Worker 1 Worker 2 Worker 3
|
||||
│ │ │
|
||||
Contract A Contract D Contract G
|
||||
↓ ↓ ↓
|
||||
[AC + B] [AC + B] [AC + B]
|
||||
Processing Processing Processing
|
||||
↓ ↓ ↓
|
||||
Success Success Failed
|
||||
│ │ │
|
||||
└────────────────┼────────────────┘
|
||||
│
|
||||
Track Results
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
Worker 1 Worker 2 Worker 3
|
||||
│ │ │
|
||||
Contract B Contract E Contract H
|
||||
↓ ↓ ↓
|
||||
Processing Processing Processing
|
||||
│ │ │
|
||||
└────────────────┼────────────────┘
|
||||
│
|
||||
↓
|
||||
Upload Progress (every 10 files)
|
||||
│
|
||||
↓
|
||||
Continue...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
This visual guide complements the detailed text guide by showing the exact flow of data and processing through the system in an easy-to-understand diagram format.
|
||||
@@ -0,0 +1,632 @@
|
||||
# Field Extraction System - Complete Guide
|
||||
|
||||
## What This System Does
|
||||
|
||||
This system reads healthcare provider contracts (like agreements between insurance companies and doctors or hospitals) and automatically extracts important information from them. Think of it as a smart assistant that reads through hundreds of pages of legal contracts and pulls out key details like payment rates, contract dates, provider names, and reimbursement terms.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [How to Run the System](#how-to-run-the-system)
|
||||
3. [Complete Processing Flow](#complete-processing-flow)
|
||||
4. [Field Types Explained](#field-types-explained)
|
||||
5. [The Extraction Process Step-by-Step](#the-extraction-process-step-by-step)
|
||||
6. [Understanding the Prompts](#understanding-the-prompts)
|
||||
7. [Output Files](#output-files)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### What Gets Extracted?
|
||||
|
||||
The system extracts two main categories of information:
|
||||
|
||||
1. **AC Fields** (Administrative/Contract Fields): Basic contract information like:
|
||||
- Contract title and effective date
|
||||
- Payer name (insurance company)
|
||||
- Provider names and identification numbers
|
||||
- Termination clauses
|
||||
- Legal terms and conditions
|
||||
|
||||
2. **B Fields** (Benefit/Reimbursement Fields): Payment-related information like:
|
||||
- What services are covered
|
||||
- How much providers get paid
|
||||
- Payment methodologies (percentages of Medicare/Medicaid rates, flat fees)
|
||||
- Which line of business the rates apply to (Medicare, Medicaid, Commercial)
|
||||
|
||||
---
|
||||
|
||||
## How to Run the System
|
||||
|
||||
### Starting the Process
|
||||
|
||||
The main entry point is **[main.py](fieldExtraction/src/main.py)**. You can run it with different configurations:
|
||||
|
||||
```bash
|
||||
python main.py batch_id=my_batch fields=abc max_workers=5
|
||||
```
|
||||
|
||||
**Key Parameters:**
|
||||
- `batch_id`: A unique name for this extraction run (e.g., "batch_january_2025")
|
||||
- `fields`: What to extract - "ac" for contract fields, "b" for reimbursement, "abc" for both
|
||||
- `max_workers`: How many contracts to process at the same time (parallel processing)
|
||||
- `read_mode`: Where to read files from ("local" or "s3")
|
||||
- `write_to_s3`: Whether to save results to cloud storage
|
||||
|
||||
---
|
||||
|
||||
## Complete Processing Flow
|
||||
|
||||
### The Big Picture
|
||||
|
||||
```
|
||||
Input Contracts (PDF → Text)
|
||||
↓
|
||||
main.py starts
|
||||
↓
|
||||
┌────────────────────────────┐
|
||||
│ Split into two paths │
|
||||
└────────────────────────────┘
|
||||
↓ ↓
|
||||
AC Processing B Processing
|
||||
(Contract Info) (Reimbursement)
|
||||
↓ ↓
|
||||
Preprocessing Preprocessing
|
||||
↓ ↓
|
||||
AI Extraction AI Extraction
|
||||
↓ ↓
|
||||
Postprocessing Postprocessing
|
||||
↓ ↓
|
||||
AC Output CSV B Output CSV
|
||||
↓ ↓
|
||||
┌────────────────────────────┐
|
||||
│ Combine & Quality Check │
|
||||
└────────────────────────────┘
|
||||
↓
|
||||
Final Reports
|
||||
```
|
||||
|
||||
### Step-by-Step Flow
|
||||
|
||||
#### 1. **Initial Setup** ([main.py:220-244](fieldExtraction/src/main.py#L220-L244))
|
||||
|
||||
When you run the system:
|
||||
- Creates a batch tracker to monitor progress
|
||||
- Reads all contract text files from the input folder (or S3 bucket)
|
||||
- Checks which files contain "reimbursement" information
|
||||
- Filters out already-processed files if requested
|
||||
|
||||
#### 2. **Parallel Processing** ([main.py:296-334](fieldExtraction/src/main.py#L296-L334))
|
||||
|
||||
The system processes multiple contracts at once:
|
||||
- Creates separate processing tasks for AC and B fields
|
||||
- Runs them in parallel using multiple "workers" (like having multiple assistants)
|
||||
- Tracks progress and handles errors for each contract
|
||||
- Updates progress statistics in real-time
|
||||
|
||||
#### 3. **Contract Processing Paths**
|
||||
|
||||
**For Each Contract:**
|
||||
- If processing AC fields → calls `process_ac()`
|
||||
- If processing B fields → calls `process_b()`
|
||||
- Each path follows its own specialized extraction process
|
||||
|
||||
---
|
||||
|
||||
## Field Types Explained
|
||||
|
||||
### AC Fields (Contract Information)
|
||||
|
||||
These fields capture the administrative details of the contract. Examples:
|
||||
|
||||
- **CONTRACT_TITLE**: The official name of the agreement
|
||||
- **PAYER_NAME**: The insurance company (e.g., "Centene", "Superior Health Plan")
|
||||
- **PROV_GROUP_NAME**: The doctor's office or hospital name
|
||||
- **CONTRACT_EFFECTIVE_DT**: When the contract starts
|
||||
- **TERMINATION_UPON_NOTICE**: How many days notice to cancel
|
||||
- **TERM_CLAUSE**: The full legal text about contract duration
|
||||
|
||||
**Why these matter**: These fields help understand who is involved in the contract, when it's valid, and basic legal terms.
|
||||
|
||||
### B Fields (Reimbursement Information)
|
||||
|
||||
These fields capture payment details. Examples:
|
||||
|
||||
- **FULL_SERVICE**: What medical service is being paid for (e.g., "Office Visits", "Surgery", "Lab Tests")
|
||||
- **FULL_METHODOLOGY**: The complete description of how payment is calculated
|
||||
- **RATE_STANDARD**: The payment rate (e.g., "110% Medicare" or "85% Medicaid")
|
||||
- **FLAT_FEE_STANDARD**: Fixed dollar amounts (e.g., "$50.00 Per Visit")
|
||||
- **LESSER_RATE**: If there's a "lesser of" clause (e.g., "100% of Billed Charges")
|
||||
- **PROV_TYPE**: Whether it's for doctors (Professional), hospitals (Facility), or other services (Ancillary)
|
||||
|
||||
**Why these matter**: These tell us exactly how much a provider gets paid for each type of service.
|
||||
|
||||
---
|
||||
|
||||
## The Extraction Process Step-by-Step
|
||||
|
||||
### AC Processing Flow
|
||||
|
||||
**Entry Point**: [file_processing.py:15-100](fieldExtraction/src/file_processing.py#L15-L100)
|
||||
|
||||
#### Step 1: Preprocessing ([preprocess.py:53-56](fieldExtraction/src/preprocess.py#L53-L56))
|
||||
|
||||
**What happens:**
|
||||
1. Clean up the text (remove weird symbols, fix line breaks)
|
||||
2. Split the contract into individual pages
|
||||
3. Create "smart chunks" - group pages by topic using keywords
|
||||
|
||||
**Smart Chunking** ([smart_chunking_funcs.py](fieldExtraction/src/smart_chunking_funcs.py)):
|
||||
- Looks for specific keywords related to each field
|
||||
- Example: For "CONTRACT_EFFECTIVE_DT", it searches for pages containing "effective date", "execution date", etc.
|
||||
- Only sends relevant pages to AI, saving time and cost
|
||||
|
||||
#### Step 2: Extract Basic Information
|
||||
|
||||
**Regex-based extraction** ([file_processing.py:36-40](fieldExtraction/src/file_processing.py#L36-L40)):
|
||||
- Some fields are found using patterns (like phone numbers or tax IDs)
|
||||
- TIN (Tax ID): Looks for 9-digit numbers with hyphen (XX-XXXXXXX)
|
||||
- NPI (Provider ID): Looks for 10-digit numbers
|
||||
|
||||
#### Step 3: Smart Chunked Prompts ([smart_chunking_funcs.py:33-150](fieldExtraction/src/smart_chunking_funcs.py#L33-L150))
|
||||
|
||||
**For each field or group of fields:**
|
||||
1. Take the relevant chunk of text (found by keywords)
|
||||
2. Build a specific question or set of questions
|
||||
3. Send to AI (Claude) with the question and text
|
||||
4. Parse the AI's response
|
||||
5. Store the answer
|
||||
|
||||
**Example for Contract Effective Date**:
|
||||
```
|
||||
Context: [Pages 1-3 of the contract]
|
||||
Question: "What is the contract effective date?"
|
||||
AI Response: "2024-01-15"
|
||||
System converts to: "2024-01-15" (standardized format)
|
||||
```
|
||||
|
||||
#### Step 4: Full Context Prompts
|
||||
|
||||
**For fields not found in chunks:**
|
||||
- Uses the entire contract as context
|
||||
- More expensive but ensures nothing is missed
|
||||
- Used for rare fields or when keyword search fails
|
||||
|
||||
#### Step 5: Postprocessing ([postprocess.py:55-114](fieldExtraction/src/postprocess.py#L55-L114))
|
||||
|
||||
**Clean up and validate:**
|
||||
- Convert dates to standard format (YYYY-MM-DD)
|
||||
- Clean up Tax IDs and NPIs
|
||||
- Derive indicator fields (Yes/No flags)
|
||||
- Add metadata (filename, page count)
|
||||
- Remove invalid values
|
||||
- Organize columns in standard order
|
||||
|
||||
### B Processing Flow
|
||||
|
||||
**Entry Point**: [file_processing.py:103-153](fieldExtraction/src/file_processing.py#L103-L153)
|
||||
|
||||
#### Step 1: Preprocessing ([preprocess.py:23-50](fieldExtraction/src/preprocess.py#L23-L50))
|
||||
|
||||
**What happens:**
|
||||
1. Clean the text
|
||||
2. Find pages with "Exhibit" or "Attachment" headers (where reimbursement info lives)
|
||||
3. Chunk consecutive pages together (keeps related compensation schedules together)
|
||||
4. Detect and format tables (many rates are in table format)
|
||||
|
||||
**Table Alignment**:
|
||||
- Contracts often have messy tables
|
||||
- System realigns columns and rows
|
||||
- Preserves the structure for accurate extraction
|
||||
|
||||
#### Step 2: Bottom-Up Extraction ([one_to_n_funcs.py](fieldExtraction/src/one_to_n_funcs.py))
|
||||
|
||||
**Primary Bottom-Up** ([prompts.py:9-196](fieldExtraction/src/prompts.py#L9-L196)):
|
||||
|
||||
This is the core reimbursement extraction. For each page:
|
||||
|
||||
**Prompt asks AI to find**:
|
||||
- Every service mentioned (e.g., "Office Visits", "Emergency Room")
|
||||
- Complete payment methodology
|
||||
- Provider type (Professional/Facility/Ancillary)
|
||||
- Any subheaders or categories
|
||||
|
||||
**Example extraction**:
|
||||
```
|
||||
Page text: "Physician Services: 110% of Medicare Fee Schedule"
|
||||
|
||||
AI extracts:
|
||||
{
|
||||
"FULL_SERVICE": "Physician Services",
|
||||
"FULL_METHODOLOGY": "110% of Medicare Fee Schedule",
|
||||
"PROV_TYPE": "Professional"
|
||||
}
|
||||
```
|
||||
|
||||
**Critical Rules in the Prompt**:
|
||||
- Extract EVERY rate on the page (don't skip anything)
|
||||
- Include "lesser of" statements (e.g., "lesser of billed charges or 100% Medicare")
|
||||
- Capture complete table context (headers + values)
|
||||
- Split services with multiple rates into separate entries
|
||||
|
||||
**Secondary Bottom-Up** ([prompts.py:198-458](fieldExtraction/src/prompts.py#L198-L458)):
|
||||
|
||||
Breaks down each methodology into structured fields:
|
||||
|
||||
**Methodology Breakout Prompt**:
|
||||
```
|
||||
Input: "Lesser of billed charges or 110% Medicare Fee Schedule"
|
||||
|
||||
AI extracts:
|
||||
{
|
||||
"LESSER": "Y",
|
||||
"RATE_STANDARD": "110% Medicare",
|
||||
"RATE_SHORT": "1.1",
|
||||
"LESSER_RATE": "100% of BC" (Billed Charges)
|
||||
}
|
||||
```
|
||||
|
||||
**Escalator Detection**:
|
||||
- Checks if rates increase over time
|
||||
- Finds effective dates for rate changes
|
||||
|
||||
#### Step 3: Top-Down Extraction ([prompts.py:482-554](fieldExtraction/src/prompts.py#L482-L554))
|
||||
|
||||
**Extracts page-level information**:
|
||||
- Exhibit names (e.g., "Attachment C: Medicare Advantage")
|
||||
- Line of Business (Medicare, Medicaid, Commercial, Dual)
|
||||
- Default terms (what happens if no rate is specified)
|
||||
- Chargemaster protections
|
||||
- Add-on reimbursement language
|
||||
|
||||
**Why both Bottom-Up and Top-Down?**
|
||||
- Bottom-up finds specific rates and services
|
||||
- Top-down finds context that applies to all services on a page
|
||||
- Combined, they give complete information
|
||||
|
||||
#### Step 4: Conditional Prompts ([conditional_funcs.py](fieldExtraction/src/conditional_funcs.py))
|
||||
|
||||
**Fills in additional details**:
|
||||
- Inpatient vs Outpatient (IP/OP)
|
||||
- More specific provider types
|
||||
- Line of Business assignment for each service
|
||||
|
||||
#### Step 5: Postprocessing ([postprocess.py:5-52](fieldExtraction/src/postprocess.py#L5-L52))
|
||||
|
||||
**Final cleanup**:
|
||||
- Filter out invalid services
|
||||
- Clean methodology text
|
||||
- Standardize Line of Business values
|
||||
- Add metadata fields
|
||||
- Rename columns to user-friendly names
|
||||
- Reorder columns
|
||||
|
||||
---
|
||||
|
||||
## Understanding the Prompts
|
||||
|
||||
### What Are Prompts?
|
||||
|
||||
Prompts are detailed instructions given to the AI (Claude). They're like giving very specific directions to a research assistant.
|
||||
|
||||
### Anatomy of a Prompt
|
||||
|
||||
Every prompt has:
|
||||
|
||||
1. **Context**: The contract text to analyze
|
||||
2. **Task**: What to extract
|
||||
3. **Instructions**: How to extract it
|
||||
4. **Examples**: Show the AI what good answers look like
|
||||
5. **Constraints**: What NOT to do
|
||||
6. **Output format**: How to return the answer (JSON, text, etc.)
|
||||
|
||||
### Example: Bottom-Up Primary Prompt
|
||||
|
||||
**Location**: [prompts.py:9-196](fieldExtraction/src/prompts.py#L9-L196)
|
||||
|
||||
```
|
||||
Structure:
|
||||
├── Give page text
|
||||
├── Explain the task ("extract EVERY reimbursement term")
|
||||
├── Define each field to extract
|
||||
│ ├── FULL_SERVICE: What service is being reimbursed?
|
||||
│ ├── FULL_METHODOLOGY: Complete payment description
|
||||
│ └── PROV_TYPE: Professional, Facility, or Ancillary
|
||||
├── Provide detailed rules
|
||||
│ ├── How to handle tables
|
||||
│ ├── How to capture "lesser of" statements
|
||||
│ └── When to split into multiple entries
|
||||
├── Give 3-5 examples of correct extractions
|
||||
└── Request JSON output
|
||||
```
|
||||
|
||||
**Key Instruction Examples**:
|
||||
- "Extract EVERY percentage rate, dollar amount, or lesser of statement"
|
||||
- "For tables, include: [Lesser of statements] + [Table header] + [Service row]"
|
||||
- "Split entries when encountering multiple percentage rates for different services"
|
||||
|
||||
### Example: Methodology Breakout Prompt
|
||||
|
||||
**Location**: [prompts.py:198-324](fieldExtraction/src/prompts.py#L198-L324)
|
||||
|
||||
**Purpose**: Parse a methodology string into structured fields
|
||||
|
||||
```
|
||||
Input: "Lesser of billed charges or 110% of Medicare Fee Schedule"
|
||||
|
||||
Prompt asks:
|
||||
1. Is there a "lesser of"? → LESSER: Y or N
|
||||
2. What's the percentage rate? → RATE_STANDARD: "110% Medicare"
|
||||
3. Convert to decimal → RATE_SHORT: "1.1"
|
||||
4. What's the lesser of rate? → LESSER_RATE: "100% of BC"
|
||||
5. Any flat fees? → FLAT_FEE_STANDARD: N/A
|
||||
6. Any "not to exceed"? → NOT_TO_EXCEED: N/A
|
||||
|
||||
Output:
|
||||
{
|
||||
"LESSER": "Y",
|
||||
"RATE_STANDARD": "110% Medicare",
|
||||
"RATE_SHORT": "1.1",
|
||||
"FLAT_FEE_STANDARD": "N/A",
|
||||
"LESSER_RATE": "100% of BC",
|
||||
"NOT_TO_EXCEED": "N/A"
|
||||
}
|
||||
```
|
||||
|
||||
**Why this structure?**
|
||||
- Makes rates searchable and comparable
|
||||
- Standardizes different ways of expressing the same thing
|
||||
- Enables analysis (e.g., "find all services paid above 100% Medicare")
|
||||
|
||||
---
|
||||
|
||||
## Preprocessing Details
|
||||
|
||||
### Text Cleaning
|
||||
|
||||
**Functions in [preprocessing_funcs.py](fieldExtraction/src/preprocessing_funcs.py)**:
|
||||
|
||||
1. **clean_newlines**: Fixes line break issues
|
||||
2. **clean_law_symbols**: Removes § symbols and other legal markers
|
||||
3. **split_text**: Divides contract into page-by-page dictionary
|
||||
|
||||
### Smart Chunking for AC Fields
|
||||
|
||||
**How it works** ([preprocessing_funcs.py](fieldExtraction/src/preprocessing_funcs.py)):
|
||||
|
||||
1. **Define keyword groups** ([keywords.py](fieldExtraction/src/keywords.py)):
|
||||
```python
|
||||
"CONTRACT_EFFECTIVE_DT": {
|
||||
"keywords": ["effective date", "execution date", "date of execution"],
|
||||
"case_sensitive": False
|
||||
}
|
||||
```
|
||||
|
||||
2. **Search each page** for keywords
|
||||
3. **Extract matching pages** (and nearby pages for context)
|
||||
4. **Build focused chunk** with only relevant text
|
||||
5. **Result**: Instead of sending 50 pages, send only 3 relevant pages
|
||||
|
||||
**Benefits**:
|
||||
- Faster processing
|
||||
- Lower cost (less text to analyze)
|
||||
- More accurate (AI focuses on relevant content)
|
||||
|
||||
### Chunking for B Fields
|
||||
|
||||
**Different approach** ([preprocess.py:24-50](fieldExtraction/src/preprocess.py#L24-L50)):
|
||||
|
||||
1. **Find exhibit pages**: Pages starting with "Exhibit", "Attachment", "Amendment"
|
||||
2. **Chunk consecutive pages**: Keep pages 10-15 together if they're part of the same exhibit
|
||||
3. **Align tables**: Detect tables and reformat them for consistency
|
||||
|
||||
**Why chunk this way?**
|
||||
- Compensation schedules span multiple pages
|
||||
- Need full context of each exhibit
|
||||
- Tables need to stay together for accurate extraction
|
||||
|
||||
---
|
||||
|
||||
## Output Files
|
||||
|
||||
### AC Output
|
||||
|
||||
**File**: `{batch_id}-AC.csv`
|
||||
|
||||
**Contains one row per contract with columns like**:
|
||||
- Contract Name
|
||||
- Contract Title
|
||||
- Payer Name
|
||||
- Provider Group Name
|
||||
- Contract Effective Date
|
||||
- Termination Terms
|
||||
- Legal Clauses
|
||||
- (40+ fields total)
|
||||
|
||||
### B Output
|
||||
|
||||
**File**: `{batch_id}-B.csv`
|
||||
|
||||
**Contains multiple rows per contract (one per service/rate) with columns like**:
|
||||
- Contract Name
|
||||
- Exhibit Name
|
||||
- Line of Business
|
||||
- Full Service Description
|
||||
- Provider Type
|
||||
- Reimbursement Methodology
|
||||
- Rate Standard
|
||||
- Rate Short (decimal)
|
||||
- Flat Fee
|
||||
- Lesser Of Indicator
|
||||
- (30+ fields total)
|
||||
|
||||
### Combined ABC Output
|
||||
|
||||
**File**: `{batch_id}-ABC.csv`
|
||||
|
||||
**Merges AC and B data**:
|
||||
- Each B row includes AC contract information
|
||||
- Enables analysis like "find all services in Medicare contracts with rates above 110%"
|
||||
|
||||
### QA/QC Report
|
||||
|
||||
**File**: Excel workbook with multiple sheets:
|
||||
- **Blanks Analysis**: Fields with missing values
|
||||
- **Date Formatting Check**: Invalid dates
|
||||
- **State Validation**: Health plan state issues
|
||||
- **TIN/NPI Validation**: Provider ID issues
|
||||
- **Y/N Field Check**: Incorrect Yes/No values
|
||||
|
||||
---
|
||||
|
||||
## Error Handling and Recovery
|
||||
|
||||
### Built-in Safeguards
|
||||
|
||||
1. **File-level tracking** ([tracking.py](fieldExtraction/src/tracking.py)):
|
||||
- Records which files are processed
|
||||
- Skips already-completed files on re-run
|
||||
- Logs errors with details
|
||||
|
||||
2. **Self-repair mode** ([main.py:170-218](fieldExtraction/src/main.py#L170-L218)):
|
||||
- Automatically retries failed files
|
||||
- Consolidates output after repairs
|
||||
|
||||
3. **Progress monitoring**:
|
||||
- Real-time progress percentages
|
||||
- Completion tracking for AC and B separately
|
||||
- Upload every 10 files for incremental results
|
||||
|
||||
### What Happens When Things Go Wrong
|
||||
|
||||
**If a file fails**:
|
||||
1. Error is logged with details
|
||||
2. Processing continues with other files
|
||||
3. Failed files marked in tracking
|
||||
4. Self-repair attempts to reprocess
|
||||
|
||||
**If AI returns bad JSON**:
|
||||
1. System tries to fix common JSON errors
|
||||
2. Falls back to asking AI to fix it ([prompts.py:727-735](fieldExtraction/src/prompts.py#L727-L735))
|
||||
3. If still broken, logs error and continues
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Why Two Separate Paths (AC and B)?
|
||||
|
||||
- **Different information types**: Contract metadata vs reimbursement details
|
||||
- **Different extraction patterns**: One-to-one (AC) vs one-to-many (B)
|
||||
- **Different prompting strategies**: Single-pass vs multi-stage
|
||||
|
||||
### Why Multiple Prompt Stages?
|
||||
|
||||
**For B fields**:
|
||||
1. **Bottom-up primary**: Find everything on the page
|
||||
2. **Bottom-up secondary**: Structure each finding
|
||||
3. **Top-down**: Add page-level context
|
||||
4. **Conditional**: Fill in gaps
|
||||
|
||||
**Reasoning**:
|
||||
- Breaking it into stages improves accuracy
|
||||
- Each stage has a focused task
|
||||
- Easier to debug and improve each stage independently
|
||||
|
||||
### Why Use AI Instead of Rules?
|
||||
|
||||
**Contracts are highly variable**:
|
||||
- Different formats (tables, paragraphs, bullet points)
|
||||
- Different terminology (same concept, different words)
|
||||
- Different structures (some have exhibits, some don't)
|
||||
- Legal language that changes meaning with small variations
|
||||
|
||||
**AI advantages**:
|
||||
- Understands context and meaning
|
||||
- Adapts to variations
|
||||
- Can follow complex instructions
|
||||
- Generalizes to new contract types
|
||||
|
||||
**Where rules ARE used**:
|
||||
- Date formatting (after extraction)
|
||||
- TIN/NPI patterns (regex)
|
||||
- Data cleanup (standardization)
|
||||
|
||||
---
|
||||
|
||||
## Performance and Scalability
|
||||
|
||||
### Processing Speed
|
||||
|
||||
**Typical performance**:
|
||||
- AC fields: ~30-60 seconds per contract
|
||||
- B fields: ~2-5 minutes per contract (depends on reimbursement complexity)
|
||||
- With 5 workers: Can process 100 contracts in ~1-2 hours
|
||||
|
||||
### Parallel Processing
|
||||
|
||||
**How it works** ([main.py:296-334](fieldExtraction/src/main.py#L296-L334)):
|
||||
```python
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
# Submit all contracts for processing
|
||||
futures = [executor.submit(process_ac, contract) for contract in contracts]
|
||||
|
||||
# Collect results as they complete
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
result = future.result()
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Process multiple contracts simultaneously
|
||||
- Better utilization of resources
|
||||
- Significant time savings on large batches
|
||||
|
||||
### Cost Management
|
||||
|
||||
**Strategies**:
|
||||
- Smart chunking reduces tokens sent to AI
|
||||
- Caching avoids re-processing
|
||||
- Progress tracking prevents duplicate work
|
||||
- Cost logging tracks spend per file ([costlog_funcs.py](fieldExtraction/src/costlog_funcs.py))
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### "No reimbursement information found"
|
||||
|
||||
**Cause**: Contract doesn't contain payment terms (might be administrative-only)
|
||||
**Solution**: Normal behavior - file is skipped for B processing, AC still processed
|
||||
|
||||
### "AC output file not created"
|
||||
|
||||
**Cause**: Error during AC processing or postprocessing
|
||||
**Solution**: Check error logs, review tracking file for details
|
||||
|
||||
### Missing fields in output
|
||||
|
||||
**Cause**: Information not found in contract OR extraction failed
|
||||
**Solution**: Check raw contract for presence of information, review prompt effectiveness
|
||||
|
||||
### Rate extraction errors
|
||||
|
||||
**Cause**: Complex table structures, unusual formatting
|
||||
**Solution**: Review table alignment step, check if manual cleanup needed
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This field extraction system automates the tedious work of reading healthcare contracts and pulling out critical information. By combining smart text processing, focused AI prompting, and thorough quality checks, it can process hundreds of contracts efficiently and accurately.
|
||||
|
||||
**The key to its success**:
|
||||
1. **Intelligent preprocessing**: Only send relevant text to AI
|
||||
2. **Structured prompting**: Clear instructions with examples
|
||||
3. **Multi-stage extraction**: Break complex tasks into manageable steps
|
||||
4. **Robust postprocessing**: Clean and validate results
|
||||
5. **Error handling**: Gracefully handle failures and retry
|
||||
|
||||
Whether you're looking to understand how the system works, modify it for new fields, or troubleshoot issues, this guide provides the foundation you need.
|
||||
@@ -0,0 +1,200 @@
|
||||
# Performance Analysis Guide
|
||||
|
||||
## Overview
|
||||
This guide explains the timing instrumentation added to the pipeline and how to interpret the results to identify bottlenecks and parallelization opportunities.
|
||||
|
||||
## Instrumented Components
|
||||
|
||||
### 1. Main Pipeline (`main.py`)
|
||||
- **`read_constants`**: Loading configuration and constants
|
||||
- **`read_input`**: Reading input files from S3 or local storage
|
||||
- **`warm_prompt_caches`**: Pre-warming Claude prompt caches
|
||||
- **`parallel_file_processing`**: Main parallel processing of all files (CRITICAL METRIC)
|
||||
- **`concat_results`**: Concatenating all file results into final dataframes
|
||||
- **`qc_qa_validation`**: Quality control and QA validation
|
||||
- **`write_outputs`**: Writing final outputs to S3/local storage
|
||||
- **`parent_child_mapping`**: Parent-child relationship mapping (if enabled)
|
||||
|
||||
### 2. Per-File Processing (`file_processing.py`)
|
||||
Each file goes through these stages (tracked with `context=filename`):
|
||||
- **`{filename}.preprocess`**: Text cleaning, splitting, header/footer detection
|
||||
- **`{filename}.one_to_n_exhibit_chunking`**: Chunking document into exhibits
|
||||
- **`{filename}.one_to_n_extraction`**: Extracting one-to-many relationships (parallel exhibits)
|
||||
- **`{filename}.one_to_n_generate_reimb_ids`**: Generating reimbursement IDs
|
||||
- **`{filename}.one_to_one_extraction`**: Extracting one-to-one fields (see below)
|
||||
- **`{filename}.merge_one_to_one_into_one_to_n`**: Merging results
|
||||
- **`{filename}.code_processing`**: Code breakout and grouper processing
|
||||
- **`{filename}.postprocess`**: Final postprocessing and validation
|
||||
- **`{filename}.write_individual`**: Writing individual file results
|
||||
|
||||
### 3. One-to-One Processing (`file_processing.py` -> `run_one_to_one_prompts`)
|
||||
- **`{filename}.one_to_one.provider_info`**: Extracting TIN/NPI provider information
|
||||
- **`{filename}.one_to_one.hybrid_smart_chunking`**: RAG-based field extraction (CRITICAL)
|
||||
- **`{filename}.one_to_one.full_context`**: Full-context field extraction (fallback)
|
||||
|
||||
### 4. Hybrid Smart Chunking (RAG) (`hybrid_smart_chunking_funcs.py`)
|
||||
This is often the bottleneck. Detailed timing:
|
||||
- **`{filename}.hsc.setup`**: Setting up embeddings, reranker, and text splitter
|
||||
- **`{filename}.hsc.preprocessing`**: Document preprocessing and chunking
|
||||
- **`{filename}.hsc.vectorstore_creation`**: Creating FAISS vectorstore + hybrid retriever
|
||||
- **`{filename}.hsc.parallel_field_processing`**: Processing all fields in parallel (max 5 workers)
|
||||
- **Per-field timing** (inside parallel processing):
|
||||
- **`{filename}.hsc.field.{field_name}.retrieval`**: BM25 + semantic retrieval + reranking
|
||||
- **`{filename}.hsc.field.{field_name}.llm_call`**: Claude API call for field extraction
|
||||
|
||||
## How to Analyze Results
|
||||
|
||||
### Step 1: Run Your Pipeline
|
||||
```bash
|
||||
poetry run python -m src.investment.main run_mode=local read_mode=local input_dir=test-multi write_to_s3=False state=MS-IL-NY-CA-TX-WI-AR-KY-TN-ME-OH-OR-FL-AZ-IN-NJ-MI max_workers=50 client=Centene-Parkland-Molina-Moda-Healthnet-Trillium
|
||||
```
|
||||
|
||||
### Step 2: Review Timing Summary
|
||||
At the end of execution, you'll see:
|
||||
1. **Total pipeline duration**
|
||||
2. **Hierarchical timing summary** organized by component
|
||||
|
||||
Look for the output sections:
|
||||
```
|
||||
================================================================================
|
||||
PIPELINE COMPLETE - Total time: XXX.XXs
|
||||
================================================================================
|
||||
HIERARCHICAL TIMING SUMMARY
|
||||
================================================================================
|
||||
```
|
||||
|
||||
### Step 3: Identify Bottlenecks
|
||||
|
||||
#### Key Metrics to Check:
|
||||
|
||||
1. **`parallel_file_processing` total time**
|
||||
- This should be the largest component
|
||||
- Compare to total pipeline time to see overhead
|
||||
- **If this is much shorter than pipeline time**: Look for overhead in setup, I/O, or postprocessing
|
||||
|
||||
2. **Per-file timings** (look at individual file contexts)
|
||||
- **Find the slowest files**: Look for files with longest total time
|
||||
- **Identify slow components within files**: Check which stage takes longest
|
||||
- Is it `one_to_one.hybrid_smart_chunking`?
|
||||
- Is it `one_to_n_extraction`?
|
||||
- Is it `postprocess`?
|
||||
|
||||
3. **Hybrid Smart Chunking (HSC) breakdown**
|
||||
- **`hsc.vectorstore_creation`**: Should be relatively quick (<5s per file)
|
||||
- **`hsc.parallel_field_processing`**: Often the bottleneck
|
||||
- Check if fields are processing in parallel (should see ~5 fields at a time)
|
||||
- Look at per-field retrieval vs llm_call times
|
||||
- **`hsc.field.{field_name}.llm_call`**: LLM latency (network + Claude processing)
|
||||
- High variance? Could be prompt cache issues or rate limiting
|
||||
- Consistently slow? Could be context size or model load
|
||||
|
||||
4. **One-to-N parallel processing**
|
||||
- Check if exhibits and pages are processing in parallel
|
||||
- Look for imbalanced workload (some exhibits much slower than others)
|
||||
|
||||
### Step 4: Optimization Opportunities
|
||||
|
||||
#### Scenario 1: HSC Vectorstore Creation is Slow
|
||||
- **Problem**: Document preprocessing/chunking is slow
|
||||
- **Solution**:
|
||||
- Consider caching vectorstores per document
|
||||
- Reduce chunk_size or chunk_overlap in HSC_CONFIG
|
||||
- Optimize preprocessing pipeline
|
||||
|
||||
#### Scenario 2: HSC LLM Calls are Slow
|
||||
- **Problem**: Too many sequential LLM calls or high latency
|
||||
- **Current**: max_workers=5 for field processing
|
||||
- **Solution**:
|
||||
- Increase `max_workers` in `hybrid_smart_chunking_funcs.py:304` (currently 5)
|
||||
- Check Bedrock connection pool (now set to 60, should be sufficient)
|
||||
- Verify prompt caching is working (check cache hits in logs)
|
||||
|
||||
#### Scenario 3: HSC Retrieval/Reranking is Slow
|
||||
- **Problem**: RAG retrieval taking too long per field
|
||||
- **Solution**:
|
||||
- Reduce `ENSEMBLE_TOP_K` or `RERANKER_TOP_K` in RAG config
|
||||
- Consider using faster reranker model
|
||||
- Optimize BM25 index size
|
||||
|
||||
#### Scenario 4: Parallel File Processing Not Saturated
|
||||
- **Problem**: Not all `max_workers` are being used
|
||||
- **Check**:
|
||||
- Connection pool exhaustion (should be fixed with `max_pool_connections=60`)
|
||||
- Memory constraints (monitor memory usage)
|
||||
- I/O bottlenecks (local disk or S3 bandwidth)
|
||||
- **Solution**:
|
||||
- Increase `max_workers` carefully (currently 50)
|
||||
- Profile memory usage to find leaks
|
||||
|
||||
#### Scenario 5: One-to-N Exhibits Not Parallel
|
||||
- **Current**: max_workers=5 for exhibit processing, max_workers=5 for page processing
|
||||
- **Solution**:
|
||||
- Increase these limits in `file_processing.py:244` (exhibits) and `:192` (pages)
|
||||
- Balance against total file-level parallelism to avoid oversubscription
|
||||
|
||||
#### Scenario 6: Postprocessing is Slow
|
||||
- **Problem**: Code processing or postprocessing taking too long
|
||||
- **Solution**:
|
||||
- Profile specific postprocessing functions
|
||||
- Look for opportunities to batch operations
|
||||
- Consider caching expensive lookups
|
||||
|
||||
## Current Parallelization Strategy
|
||||
|
||||
### Three Levels of Parallelism:
|
||||
|
||||
1. **File Level** (main.py)
|
||||
- `max_workers=50` (from command line arg)
|
||||
- 50 files processed simultaneously
|
||||
|
||||
2. **Exhibit/Page Level** (within each file)
|
||||
- Exhibits: `max_workers=5` per file
|
||||
- Pages: `max_workers=5` per exhibit
|
||||
- This means potentially 50 files × 5 exhibits × 5 pages = 1,250 threads (too many!)
|
||||
- **Currently limited by nested executors**
|
||||
|
||||
3. **Field Level** (within HSC per file)
|
||||
- `max_workers=5` for field extraction
|
||||
- 5 fields extracted in parallel per file
|
||||
|
||||
### Total Theoretical Concurrency:
|
||||
- **Bedrock API calls**: Up to 60 concurrent (connection pool limit)
|
||||
- **Thread pools**: Nested executors create complex concurrency
|
||||
- **Bottleneck**: Likely the 60-connection Bedrock pool with 50 file workers
|
||||
|
||||
## Recommendations After First Run
|
||||
|
||||
1. **Run with timing enabled** and review the hierarchical summary
|
||||
2. **Identify the top 3 slowest components** by total time
|
||||
3. **Check if parallelism is effective**:
|
||||
- Are all max_workers being utilized?
|
||||
- Is there thread contention or blocking?
|
||||
4. **Look for outliers**: Files that take much longer than others
|
||||
5. **Profile memory usage**: Ensure no memory leaks with many threads
|
||||
6. **Monitor Bedrock throttling**: Check for rate limit errors in logs
|
||||
|
||||
## Next Steps
|
||||
|
||||
Based on timing results, we can:
|
||||
1. **Increase parallelism** where bottlenecks are found
|
||||
2. **Add caching** for repeated operations (vectorstores, embeddings)
|
||||
3. **Optimize prompts** to reduce token count and latency
|
||||
4. **Batch operations** where sequential processing is required
|
||||
5. **Add more granular timing** to specific slow functions
|
||||
|
||||
## Timing Log Format
|
||||
|
||||
Timing logs appear as:
|
||||
```
|
||||
INFO - ⏱️ read_constants: 0.50s
|
||||
INFO - ⏱️ parallel_file_processing: 450.23s
|
||||
DEBUG - ⏱️ file1.pdf.preprocess: 2.34s
|
||||
DEBUG - ⏱️ file1.pdf.one_to_one.hybrid_smart_chunking: 45.67s
|
||||
DEBUG - ⏱️ file1.pdf.hsc.setup: 0.12s
|
||||
DEBUG - ⏱️ file1.pdf.hsc.vectorstore_creation: 3.45s
|
||||
DEBUG - ⏱️ file1.pdf.hsc.parallel_field_processing: 40.12s
|
||||
DEBUG - ⏱️ file1.pdf.hsc.field.EFFECTIVE_DT.retrieval: 0.89s
|
||||
DEBUG - ⏱️ file1.pdf.hsc.field.EFFECTIVE_DT.llm_call: 2.34s
|
||||
```
|
||||
|
||||
Use these to drill down into specific bottlenecks!
|
||||
@@ -1,6 +1,7 @@
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import concurrent.futures
|
||||
import os
|
||||
import re
|
||||
|
||||
@@ -720,15 +721,20 @@ def code_breakout(merged_results: pd.DataFrame, constants: Constants):
|
||||
Returns:
|
||||
pd.DataFrame: DataFrame with updated code fields.
|
||||
"""
|
||||
import concurrent.futures
|
||||
|
||||
# Fill empty AARETE_DERIVED_CLAIM_TYPE_CD with mode
|
||||
answer_dicts = fill_claim_type(merged_results.to_dict(orient="records"))
|
||||
|
||||
answer_dicts_with_code = []
|
||||
for answer_dict in answer_dicts:
|
||||
# Parallelize code extraction
|
||||
def process_single_code(answer_dict):
|
||||
code_answer_dict = extract_codes_from_service(answer_dict, constants)
|
||||
answer_dict.update(code_answer_dict)
|
||||
answer_dicts_with_code.append(answer_dict)
|
||||
return answer_dict
|
||||
|
||||
max_workers = min(len(answer_dicts), 10)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
answer_dicts_with_code = list(executor.map(process_single_code, answer_dicts))
|
||||
|
||||
# Fill in GROUPER_CD_DESC based on GROUPER_TYPE and GROUPER_CD
|
||||
answer_dicts_with_code = [
|
||||
@@ -754,32 +760,52 @@ def grouper_breakout(results_with_code: pd.DataFrame):
|
||||
Returns:
|
||||
pd.DataFrame: DataFrame with updated grouper fields.
|
||||
"""
|
||||
|
||||
# try grouper fields again if they are empty and grouper_cd is not empty
|
||||
answer_dicts_with_code = results_with_code.to_dict(orient="records")
|
||||
final_answer_dicts = []
|
||||
GROUPER_QUESTIONS = FieldSet(
|
||||
file_path=config.FIELD_JSON_PATH, field_type="grouper_breakout"
|
||||
).print_prompt_dict()
|
||||
|
||||
# Separate dicts that need grouper breakout from those that don't
|
||||
needs_breakout = []
|
||||
no_breakout_needed = []
|
||||
|
||||
for answer_dict in answer_dicts_with_code:
|
||||
if string_utils.is_empty(
|
||||
answer_dict.get("GROUPER_TYPE")
|
||||
) and not string_utils.is_empty(answer_dict.get("GROUPER_CD")):
|
||||
# run groper breakout prompt
|
||||
grouper_breakout_prompt = prompt_templates.GROUPER_BREAKOUT(
|
||||
answer_dict.get("SERVICE_TERM", ""),
|
||||
answer_dict.get("REIMB_TERM", ""),
|
||||
GROUPER_QUESTIONS,
|
||||
)
|
||||
claude_answer_raw = llm_utils.invoke_claude(
|
||||
grouper_breakout_prompt, "sonnet_latest", ""
|
||||
)
|
||||
try:
|
||||
grouper_answer = string_utils.universal_json_load(claude_answer_raw)
|
||||
except Exception as e:
|
||||
grouper_answer = {"GROUPER_TYPE": e}
|
||||
needs_breakout.append(answer_dict)
|
||||
else:
|
||||
no_breakout_needed.append(answer_dict)
|
||||
|
||||
answer_dict.update(grouper_answer)
|
||||
final_answer_dicts.append(answer_dict)
|
||||
logging.debug(f"Grouper breakout: {len(needs_breakout)} rows need LLM processing, {len(no_breakout_needed)} rows skip")
|
||||
|
||||
return pd.DataFrame(final_answer_dicts)
|
||||
# Parallelize LLM calls for rows that need breakout
|
||||
def process_grouper_breakout(answer_dict):
|
||||
grouper_breakout_prompt = prompt_templates.GROUPER_BREAKOUT(
|
||||
answer_dict.get("SERVICE_TERM", ""),
|
||||
answer_dict.get("REIMB_TERM", ""),
|
||||
GROUPER_QUESTIONS,
|
||||
)
|
||||
claude_answer_raw = llm_utils.invoke_claude(
|
||||
grouper_breakout_prompt, "sonnet_latest", ""
|
||||
)
|
||||
try:
|
||||
grouper_answer = string_utils.universal_json_load(claude_answer_raw)
|
||||
except Exception as e:
|
||||
grouper_answer = {"GROUPER_TYPE": e}
|
||||
|
||||
answer_dict.update(grouper_answer)
|
||||
return answer_dict
|
||||
|
||||
if needs_breakout:
|
||||
max_workers = min(len(needs_breakout), 10)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
processed_breakout = list(executor.map(process_grouper_breakout, needs_breakout))
|
||||
else:
|
||||
processed_breakout = []
|
||||
|
||||
# Combine all results
|
||||
final_answer_dicts = no_breakout_needed + processed_breakout
|
||||
|
||||
return pd.DataFrame(final_answer_dicts)
|
||||
|
||||
@@ -193,7 +193,7 @@ if RUN_MODE == "local":
|
||||
AWS_SESSION_TOKEN = get_value_from_env("AWS_SESSION_TOKEN")
|
||||
|
||||
# Bedrock
|
||||
config = Config(read_timeout=2000)
|
||||
config = Config(read_timeout=2000, max_pool_connections=60)
|
||||
BEDROCK_RUNTIME = boto3.client(
|
||||
service_name="bedrock-runtime",
|
||||
region_name="us-east-1",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import logging
|
||||
import concurrent.futures
|
||||
|
||||
import src.investment.prompt_calls as prompt_calls
|
||||
import src.utils.llm_utils as llm_utils
|
||||
import src.utils.string_utils as string_utils
|
||||
from src.investment import prompt_calls, preprocessing_funcs
|
||||
from src.utils import string_utils
|
||||
from constants.constants import Constants
|
||||
from src import config
|
||||
from src.prompts import prompt_templates
|
||||
from src.prompts.fieldset import Field, FieldSet
|
||||
from src.investment.exhibit_funcs import Exhibit
|
||||
|
||||
|
||||
def dynamic_primary(
|
||||
@@ -140,24 +141,38 @@ def dynamic(
|
||||
return exhibit_level_answers, dynamic_reimbursement_fields
|
||||
|
||||
|
||||
def dynamic_assignment(reimbursement_primary_answers: list[dict[str, str]], dynamic_reimbursement_fields: FieldSet, exhibit_text_simplified: str, page_num: str, constants: Constants, filename: str) -> list[dict[str, str]]:
|
||||
|
||||
# Process each answer and field individually
|
||||
answer_dicts = []
|
||||
def dynamic_assignment(reimbursement_primary_answers: list[dict[str, str]],
|
||||
dynamic_reimbursement_fields: FieldSet,
|
||||
text_dict: dict[str, str],
|
||||
exhibit: Exhibit,
|
||||
constants: Constants,
|
||||
filename: str
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
Enrich reimbursement rows with dynamic fields in parallel.
|
||||
"""
|
||||
if not reimbursement_primary_answers or not dynamic_reimbursement_fields.fields:
|
||||
return reimbursement_primary_answers
|
||||
|
||||
# TODO: Cache here
|
||||
# instruction, prompt = prompt_templates.DYNAMIC_ASSIGNMENT
|
||||
|
||||
# TODO: Multithread this loop
|
||||
for answer_dict in reimbursement_primary_answers:
|
||||
service_term = answer_dict['SERVICE_TERM']
|
||||
reimb_term = answer_dict['REIMB_TERM']
|
||||
def _assign_single(answer_dict: dict[str, str]) -> dict[str, str]:
|
||||
# copy to avoid mutating caller-provided dicts when running in threads
|
||||
updated = answer_dict.copy()
|
||||
page_num = updated["REIMB_PAGE"]
|
||||
exhibit_text_simplified = preprocessing_funcs.simplify_exhibit(
|
||||
text_dict, exhibit.exhibit_page_nums, page_num
|
||||
)
|
||||
service_term = updated['SERVICE_TERM']
|
||||
reimb_term = updated['REIMB_TERM']
|
||||
for dynamic_field in dynamic_reimbursement_fields.fields:
|
||||
dynamic_field_answer = prompt_calls.prompt_dynamic_assignment(service_term, reimb_term, dynamic_field, exhibit_text_simplified, page_num, constants, filename)
|
||||
answer_dict.update(dynamic_field_answer)
|
||||
answer_dicts.append(answer_dict)
|
||||
|
||||
return answer_dicts
|
||||
dynamic_field_answer = prompt_calls.prompt_dynamic_assignment(
|
||||
service_term, reimb_term, dynamic_field, exhibit_text_simplified, page_num, constants, filename
|
||||
)
|
||||
updated.update(dynamic_field_answer)
|
||||
return updated
|
||||
|
||||
max_workers = min(len(reimbursement_primary_answers), 8)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
return list(executor.map(_assign_single, reimbursement_primary_answers))
|
||||
|
||||
|
||||
def add_one_to_one_field(one_to_one_fields, field_to_add, answer_dicts, constants: Constants):
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Exhibit class for tracking exhibit state during one-to-n processing.
|
||||
|
||||
This module provides the Exhibit class which maintains all state and field values
|
||||
for a single exhibit during field extraction, including a linked list structure
|
||||
for propagating dynamic fields from exhibit n-1 to exhibit n.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from src.prompts.fieldset import FieldSet
|
||||
|
||||
|
||||
class Exhibit:
|
||||
"""
|
||||
Tracks all state and field values for a single exhibit during processing.
|
||||
|
||||
Maintains a linked list structure via prev_exhibit to enable field propagation
|
||||
from exhibit n-1 to exhibit n during dynamic assignment.
|
||||
|
||||
Attributes:
|
||||
exhibit_page (str): The page number where this exhibit starts
|
||||
exhibit_page_nums (list[str]): All page numbers that belong to this exhibit
|
||||
exhibit_header (str): The header text of the exhibit
|
||||
exhibit_text (str): The full text content of the exhibit
|
||||
num_pages (int): Number of pages in this exhibit
|
||||
prev_exhibit (Optional[Exhibit]): Pointer to the previous exhibit (n-1)
|
||||
dynamic_primary_fields (FieldSet): Dynamic primary fields from exhibit_level
|
||||
dynamic_reimbursement_fields (FieldSet): Dynamic reimbursement fields from exhibit_level
|
||||
exhibit_level_answers (dict): Exhibit-level field answers
|
||||
reimbursement_rows (list[dict]): Reimbursement rows from page processing
|
||||
special_case_rows (list[dict]): Special case rows from page processing
|
||||
has_reimbursements (bool): Whether this exhibit has any reimbursements
|
||||
final_rows (list[dict]): Final combined and cleaned rows
|
||||
"""
|
||||
|
||||
def __init__(self, exhibit_page: str, exhibit_page_nums: list[str],
|
||||
exhibit_header: str, exhibit_text: str, prev_exhibit: Optional['Exhibit'] = None):
|
||||
"""
|
||||
Initialize a new Exhibit instance.
|
||||
|
||||
Args:
|
||||
exhibit_page: The starting page number for this exhibit
|
||||
exhibit_page_nums: List of all page numbers in this exhibit
|
||||
exhibit_header: The header text of the exhibit
|
||||
exhibit_text: The full text content of the exhibit
|
||||
prev_exhibit: Optional pointer to the previous exhibit for field propagation
|
||||
"""
|
||||
# Basic exhibit metadata
|
||||
self.exhibit_page = exhibit_page
|
||||
self.exhibit_page_nums = exhibit_page_nums
|
||||
self.exhibit_header = exhibit_header
|
||||
self.exhibit_text = exhibit_text
|
||||
self.num_pages = len(exhibit_page_nums)
|
||||
|
||||
# Linked list for exhibit chain
|
||||
self.prev_exhibit = prev_exhibit
|
||||
|
||||
# Field tracking (populated in Step 2)
|
||||
self.dynamic_primary_fields = FieldSet()
|
||||
self.dynamic_reimbursement_fields = FieldSet()
|
||||
self.exhibit_level_answers = {}
|
||||
|
||||
# Reimbursement data (populated in Step 1)
|
||||
self.reimbursement_rows = []
|
||||
self.special_case_rows = []
|
||||
self.has_reimbursements = False
|
||||
|
||||
# Final combined rows (populated in Step 3)
|
||||
self.final_rows = []
|
||||
|
||||
def add_reimbursement_rows(self, reimbursement_rows: list[dict], special_case_rows: list[dict]):
|
||||
"""
|
||||
Add reimbursement rows from page processing.
|
||||
|
||||
This is called during Step 1 (parallel page processing) to accumulate
|
||||
reimbursement and special case rows from all pages in this exhibit.
|
||||
|
||||
Args:
|
||||
reimbursement_rows: List of reimbursement dictionaries from a page
|
||||
special_case_rows: List of special case dictionaries from a page
|
||||
"""
|
||||
self.reimbursement_rows.extend(reimbursement_rows)
|
||||
self.special_case_rows.extend(special_case_rows)
|
||||
self.has_reimbursements = len(reimbursement_rows) > 0 or len(special_case_rows) > 0
|
||||
|
||||
def set_exhibit_level_data(self, exhibit_level_answers: dict, dynamic_reimbursement_fields: FieldSet):
|
||||
"""
|
||||
Set exhibit-level field data from exhibit_level processing.
|
||||
|
||||
This is called during Step 2 (exhibit-level processing) to store the
|
||||
exhibit-level answers and dynamic fields that will be used in Step 3.
|
||||
|
||||
Args:
|
||||
exhibit_level_answers: Dictionary of exhibit-level field answers
|
||||
dynamic_reimbursement_fields: FieldSet of dynamic reimbursement fields
|
||||
"""
|
||||
self.exhibit_level_answers = exhibit_level_answers
|
||||
self.dynamic_reimbursement_fields = dynamic_reimbursement_fields
|
||||
|
||||
# Extract dynamic primary fields from exhibit level answers
|
||||
# These are the fields that can be propagated to next exhibit
|
||||
self.dynamic_primary_fields = dynamic_reimbursement_fields
|
||||
|
||||
def get_previous_exhibit_dynamic_fields(self) -> FieldSet:
|
||||
"""
|
||||
Get dynamic primary fields from previous exhibit (n-1) if available.
|
||||
|
||||
This enables field propagation across exhibits, allowing exhibit n to
|
||||
inherit dynamic fields from exhibit n-1 when appropriate.
|
||||
|
||||
Returns:
|
||||
FieldSet: The previous exhibit's dynamic primary fields, or an empty
|
||||
FieldSet if there is no previous exhibit
|
||||
"""
|
||||
if self.prev_exhibit is not None:
|
||||
return self.prev_exhibit.dynamic_primary_fields
|
||||
return FieldSet()
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation for debugging."""
|
||||
return (f"Exhibit(page={self.exhibit_page}, pages={len(self.exhibit_page_nums)}, "
|
||||
f"has_reimb={self.has_reimbursements}, reimb_count={len(self.reimbursement_rows)})")
|
||||
|
||||
|
||||
|
||||
def get_exhibit_list(text_dict: dict[str, str], exhibit_chunk_mapping: dict[str, str], all_exhibit_headers: dict[str, str]) -> list[Exhibit]:
|
||||
exhibits = []
|
||||
prev_exhibit = None
|
||||
for exhibit_page, exhibit_page_nums in exhibit_chunk_mapping.items():
|
||||
exhibit_text = "\n".join([text_dict[page_num] for page_num in exhibit_page_nums])
|
||||
exhibit_header = all_exhibit_headers.get(exhibit_page, "")
|
||||
exhibit = Exhibit(
|
||||
exhibit_page=exhibit_page,
|
||||
exhibit_page_nums=exhibit_page_nums,
|
||||
exhibit_header=exhibit_header,
|
||||
exhibit_text=exhibit_text,
|
||||
prev_exhibit=prev_exhibit
|
||||
)
|
||||
exhibits.append(exhibit)
|
||||
prev_exhibit = exhibit
|
||||
return exhibits
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import concurrent.futures
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
import src.codes.code_funcs as code_funcs
|
||||
from src.investment import preprocessing_funcs, aarete_derived, dynamic_funcs, one_to_n_funcs, one_to_one_funcs, postprocess, postprocessing_funcs, preprocess, hybrid_smart_chunking_funcs, row_funcs, tin_npi_funcs
|
||||
from src.utils import io_utils, logging_utils, string_utils
|
||||
from src.investment import preprocessing_funcs, aarete_derived, dynamic_funcs, one_to_n_funcs, one_to_one_funcs, postprocess, postprocessing_funcs, preprocess, hybrid_smart_chunking_funcs, row_funcs, tin_npi_funcs, exhibit_funcs
|
||||
from src.investment.exhibit_funcs import Exhibit
|
||||
from src.utils import io_utils, logging_utils, string_utils, timing_utils
|
||||
from constants.constants import Constants
|
||||
from src import config
|
||||
from src.prompts.fieldset import FieldSet, Field
|
||||
from src.prompts.fieldset import FieldSet
|
||||
from src.utils.string_utils import datetime_str
|
||||
|
||||
|
||||
@@ -27,32 +29,37 @@ def process_file(file_object, constants: Constants, run_timestamp):
|
||||
final_results = pd.DataFrame([{"FILE_NAME": filename}])
|
||||
|
||||
################## PREPROCESS ##################
|
||||
contract_text = preprocess.clean_text(contract_text)
|
||||
text_dict, top_sheet_dict = preprocess.split_text(contract_text)
|
||||
text_dict, header, footer = preprocess.find_headers_and_footers(text_dict)
|
||||
with timing_utils.timed_block("preprocess", context=filename):
|
||||
contract_text = preprocess.clean_text(contract_text)
|
||||
text_dict, top_sheet_dict = preprocess.split_text(contract_text)
|
||||
text_dict, header, footer = preprocess.find_headers_and_footers(text_dict)
|
||||
|
||||
# ONE TO N PROCESSING
|
||||
one_to_n_results = pd.DataFrame() # Initialize empty DataFrame for one_to_n_results
|
||||
if process_one_to_n:
|
||||
exhibit_chunk_mapping, all_exhibit_headers = preprocess.one_to_n_exhibit_chunking(
|
||||
text_dict, constants.EXHIBIT_HEADER_MARKERS, filename
|
||||
)
|
||||
logging.info(f"{datetime_str()} Preprocessing Complete - {filename}")
|
||||
|
||||
one_to_n_results = pd.DataFrame([{"FILE_NAME": filename}]) # Initialize here
|
||||
|
||||
if string_utils.contains_reimbursement(contract_text):
|
||||
one_to_n_results, dynamic_one_to_one_fields, first_reimbursement_page = run_one_to_n_prompts(
|
||||
text_dict, exhibit_chunk_mapping, all_exhibit_headers, constants, filename
|
||||
with timing_utils.timed_block("one_to_n_exhibit_chunking", context=filename):
|
||||
exhibit_chunk_mapping, all_exhibit_headers = preprocess.one_to_n_exhibit_chunking(
|
||||
text_dict, constants.EXHIBIT_HEADER_MARKERS, filename
|
||||
)
|
||||
logging.info(f"{datetime_str()} Preprocessing Complete - {filename}")
|
||||
|
||||
one_to_n_results = pd.DataFrame([{"FILE_NAME": filename}]) # Initialize here
|
||||
|
||||
if string_utils.contains_reimbursement(contract_text):
|
||||
logging.info(f"{datetime_str()} Starting One-to-N extraction for {len(exhibit_chunk_mapping)} exhibits - {filename}")
|
||||
with timing_utils.timed_block("one_to_n_extraction", context=filename):
|
||||
one_to_n_results, dynamic_one_to_one_fields, first_reimbursement_page = run_one_to_n_prompts(
|
||||
text_dict, exhibit_chunk_mapping, all_exhibit_headers, constants, filename
|
||||
)
|
||||
if not one_to_n_results.empty:
|
||||
one_to_n_results["FILE_NAME"] = filename
|
||||
one_to_n_results = postprocessing_funcs.generate_reimb_ids(one_to_n_results)
|
||||
with timing_utils.timed_block("one_to_n_generate_reimb_ids", context=filename):
|
||||
one_to_n_results = postprocessing_funcs.generate_reimb_ids(one_to_n_results)
|
||||
logging.info(f"{datetime_str()} One to N Complete - {filename}")
|
||||
else:
|
||||
first_reimbursement_page = '1'
|
||||
logging.info(f"{datetime_str()} No Reimbursement Found, Skipping - {filename}")
|
||||
|
||||
|
||||
final_results = one_to_n_results # Set as final results
|
||||
else:
|
||||
first_reimbursement_page = '1'
|
||||
@@ -60,46 +67,51 @@ def process_file(file_object, constants: Constants, run_timestamp):
|
||||
|
||||
# ONE TO ONE PROCESSING
|
||||
if process_one_to_one:
|
||||
one_to_one_results = run_one_to_one_prompts(
|
||||
filename,
|
||||
contract_text,
|
||||
text_dict,
|
||||
top_sheet_dict,
|
||||
dynamic_one_to_one_fields,
|
||||
first_reimbursement_page,
|
||||
constants,
|
||||
)
|
||||
with timing_utils.timed_block("one_to_one_extraction", context=filename):
|
||||
one_to_one_results = run_one_to_one_prompts(
|
||||
filename,
|
||||
contract_text,
|
||||
text_dict,
|
||||
top_sheet_dict,
|
||||
dynamic_one_to_one_fields,
|
||||
first_reimbursement_page,
|
||||
constants,
|
||||
)
|
||||
one_to_one_results["FILE_NAME"] = filename
|
||||
logging.info(f"{datetime_str()} One to One Complete - {filename}")
|
||||
|
||||
|
||||
# Decide how to handle one_to_one results
|
||||
if not one_to_n_results.empty:
|
||||
# BOTH processed - merge into one_to_n
|
||||
final_results = row_funcs.merge_one_to_one_into_one_to_n(
|
||||
one_to_n_results, one_to_one_results, constants
|
||||
)
|
||||
else:
|
||||
# ONLY one_to_one processed - convert dict to DataFrame
|
||||
final_results = pd.DataFrame([one_to_one_results])
|
||||
with timing_utils.timed_block("merge_one_to_one_into_one_to_n", context=filename):
|
||||
if not one_to_n_results.empty:
|
||||
# BOTH processed - merge into one_to_n
|
||||
final_results = row_funcs.merge_one_to_one_into_one_to_n(
|
||||
one_to_n_results, one_to_one_results, constants
|
||||
)
|
||||
else:
|
||||
# ONLY one_to_one processed - convert dict to DataFrame
|
||||
final_results = pd.DataFrame([one_to_one_results])
|
||||
else:
|
||||
logging.info(f"{datetime_str()} Fields not configured for One to One, Skipping - {filename}")
|
||||
|
||||
# APPLY CODES IF ONE_TO_N WAS PROCESSED
|
||||
if not one_to_n_results.empty:
|
||||
results_with_code = code_funcs.code_breakout(final_results, constants)
|
||||
final_results = code_funcs.grouper_breakout(results_with_code)
|
||||
with timing_utils.timed_block("code_processing", context=filename):
|
||||
results_with_code = code_funcs.code_breakout(final_results, constants)
|
||||
final_results = code_funcs.grouper_breakout(results_with_code)
|
||||
logging.info(f"{datetime_str()} Codes Complete - {filename}")
|
||||
|
||||
|
||||
|
||||
# POSTPROCESS
|
||||
final_df = postprocess.postprocess(final_results, constants)
|
||||
with timing_utils.timed_block("postprocess", context=filename):
|
||||
final_df = postprocess.postprocess(final_results, constants)
|
||||
logging.info(f"{datetime_str()} Postprocessing Complete - {filename}")
|
||||
|
||||
|
||||
################## WRITE INDIVIDUAL ##################
|
||||
if config.WRITE_TO_S3:
|
||||
io_utils.write_s3(final_df, filename, run_timestamp, "individual")
|
||||
else:
|
||||
io_utils.write_local(final_df, filename, "", "individual")
|
||||
with timing_utils.timed_block("write_individual", context=filename):
|
||||
if config.WRITE_TO_S3:
|
||||
io_utils.write_s3(final_df, filename, run_timestamp, "individual")
|
||||
else:
|
||||
io_utils.write_local(final_df, filename, "", "individual")
|
||||
|
||||
logging.info(f"{datetime_str()} Writing Complete - {filename}")
|
||||
|
||||
@@ -121,22 +133,25 @@ def run_one_to_one_prompts(
|
||||
relationship="one_to_one", file_path=config.FIELD_JSON_PATH
|
||||
).combine(dynamic_one_to_one_fields)
|
||||
|
||||
################## RUN PROVIDER INFO ##################
|
||||
with timing_utils.timed_block("one_to_one.provider_info", context=filename):
|
||||
one_to_one_results = tin_npi_funcs.run_provider_info_fields(
|
||||
contract_text, text_dict, filename, payer_name=""
|
||||
)
|
||||
|
||||
################## RUN HYBRID SMART CHUNKED PROMPTS ##################
|
||||
# RAG function loads retrieval questions internally and matches with investment_prompts.json
|
||||
hybrid_smart_chunked_answers_dict = hybrid_smart_chunking_funcs.run_hybrid_smart_chunked_fields(
|
||||
one_to_one_fields, constants, contract_text, filename, text_dict
|
||||
)
|
||||
with timing_utils.timed_block("one_to_one.hybrid_smart_chunking", context=filename):
|
||||
hybrid_smart_chunked_answers_dict = hybrid_smart_chunking_funcs.run_hybrid_smart_chunked_fields(
|
||||
one_to_one_fields, constants, contract_text, filename, text_dict
|
||||
)
|
||||
|
||||
|
||||
################## RUN PROVIDER INFO ##################
|
||||
payer_name = hybrid_smart_chunked_answers_dict.get('PAYER_NAME')
|
||||
one_to_one_results = tin_npi_funcs.run_provider_info_fields(
|
||||
contract_text, text_dict, filename, payer_name
|
||||
)
|
||||
|
||||
################## RUN FULL CONTEXT PROMPTS ##################
|
||||
full_context_answers_dict = one_to_one_funcs.run_full_context_fields(
|
||||
one_to_one_fields, contract_text, text_dict, first_reimbursement_page, constants, filename
|
||||
)
|
||||
with timing_utils.timed_block("one_to_one.full_context", context=filename):
|
||||
full_context_answers_dict = one_to_one_funcs.run_full_context_fields(
|
||||
one_to_one_fields, contract_text, text_dict, first_reimbursement_page, constants, filename
|
||||
)
|
||||
################## COMBINE ANSWERS ################
|
||||
# Prefer HSC answers over full_context when HSC value is not N/A
|
||||
for key, value in full_context_answers_dict.items():
|
||||
@@ -168,111 +183,12 @@ def run_one_to_one_prompts(
|
||||
return one_to_one_results[0]
|
||||
|
||||
|
||||
def run_one_to_n_prompts(text_dict: dict[str, str],
|
||||
exhibit_chunk_mapping: dict[str, list[str]],
|
||||
all_exhibit_headers: dict[str, str],
|
||||
constants: Constants,
|
||||
filename: str):
|
||||
|
||||
one_to_n_results = []
|
||||
first_reimbursement_page = '1'
|
||||
previous_exhibit = None
|
||||
|
||||
#################################### PROCESS EACH EXHIBIT SEPARATELY ####################################
|
||||
for exhibit_page, exhibit_page_nums in exhibit_chunk_mapping.items():
|
||||
|
||||
exhibit_text_original = "\n".join([text_dict[page_num] for page_num in exhibit_page_nums])
|
||||
exhibit_header = all_exhibit_headers.get(exhibit_page, "")
|
||||
|
||||
############################### Get Exhibit Level ###############################
|
||||
exhibit_level_answers, dynamic_reimbursement_fields = (
|
||||
one_to_n_funcs.exhibit_level(
|
||||
exhibit_text_original,
|
||||
exhibit_header,
|
||||
exhibit_page,
|
||||
constants,
|
||||
filename,
|
||||
)
|
||||
) # dict, FieldSet
|
||||
logging.debug(f"Exhibit Level Answers for {filename}, Page {exhibit_page}: {exhibit_level_answers}")
|
||||
|
||||
# Store original values before inheritance (for tracking in previous_exhibit)
|
||||
original_exhibit_page_nums = exhibit_page_nums.copy()
|
||||
original_exhibit_text = exhibit_text_original
|
||||
original_dynamic_reimbursement_fields = dynamic_reimbursement_fields
|
||||
|
||||
############################### Check and Combine Exhibit Inheritance ###############################
|
||||
# IF dynamic_reimbursement_fields is empty FieldSet, and previous exhibit's dynamic_reimbursement_fields is NOT empty,
|
||||
# and the previous exhibit has NO reimbursement rows, then replace dynamic_reimbursement_fields with the previous
|
||||
# exhibit's dynamic_reimbursement_field values AND set exhibit_page_nums = exhibit_page_nums for exhibit N-1 + exhibit_page_nums for exhibit N
|
||||
should_inherit, exhibit_page_nums, dynamic_reimbursement_fields = (
|
||||
one_to_n_funcs.check_and_combine_exhibit_inheritance(
|
||||
previous_exhibit,
|
||||
exhibit_page_nums,
|
||||
exhibit_text_original,
|
||||
dynamic_reimbursement_fields,
|
||||
)
|
||||
)
|
||||
|
||||
all_exhibit_reimbursements, all_exhibit_special_case = [], []
|
||||
#################################### PAGE-BY-PAGE REIMBURSEMENT PRIMARY ####################################
|
||||
for page_num in exhibit_page_nums:
|
||||
reimbursement_level_answers, special_case_answers, first_reimbursement_page = process_page_one_to_n(
|
||||
text_dict,
|
||||
exhibit_page_nums,
|
||||
exhibit_page,
|
||||
page_num,
|
||||
first_reimbursement_page,
|
||||
dynamic_reimbursement_fields,
|
||||
filename,
|
||||
constants
|
||||
)
|
||||
all_exhibit_reimbursements += reimbursement_level_answers
|
||||
all_exhibit_special_case += special_case_answers
|
||||
|
||||
################################ Combine Answers ###############################
|
||||
all_exhibit_rows = row_funcs.combine_one_to_n(
|
||||
exhibit_text_original,
|
||||
all_exhibit_reimbursements,
|
||||
all_exhibit_special_case,
|
||||
exhibit_level_answers,
|
||||
filename
|
||||
) # returns list of dicts
|
||||
|
||||
################################ Mapping and Cleaning ###############################
|
||||
all_exhibit_rows = one_to_n_funcs.one_to_n_cleaning(all_exhibit_rows, exhibit_text_original, constants, filename)
|
||||
|
||||
################################ Add to Total ###############################
|
||||
one_to_n_results += all_exhibit_rows
|
||||
|
||||
|
||||
################################ Store Current Exhibit for Next Iteration ###############################
|
||||
# Store original (non-combined) values for next exhibit to potentially inherit from
|
||||
# Determine if current exhibit had reimbursements
|
||||
had_reimbursements = len(all_exhibit_reimbursements) > 0 or len(all_exhibit_special_case) > 0
|
||||
|
||||
previous_exhibit = {
|
||||
'exhibit_page': exhibit_page,
|
||||
'exhibit_page_nums': original_exhibit_page_nums,
|
||||
'exhibit_text_original': original_exhibit_text,
|
||||
'dynamic_reimbursement_fields': original_dynamic_reimbursement_fields,
|
||||
'exhibit_level_answers': exhibit_level_answers.copy(),
|
||||
'had_reimbursements': had_reimbursements
|
||||
}
|
||||
|
||||
################## Add N/A Dynamic or Exhibit to One-to-One ##################
|
||||
dynamic_one_to_one_fields = dynamic_funcs.get_dynamic_one_to_one_fields(
|
||||
one_to_n_results, constants
|
||||
)
|
||||
|
||||
################## CONVERT TO DF ##################
|
||||
one_to_n_df = pd.DataFrame(one_to_n_results)
|
||||
|
||||
return one_to_n_df, dynamic_one_to_one_fields, first_reimbursement_page
|
||||
|
||||
|
||||
def process_page_one_to_n(text_dict: dict[str, str], exhibit_page_nums: list[str], exhibit_page: str, page_num: str, first_reimbursement_page: str, dynamic_reimbursement_fields: FieldSet, filename: str, constants: Constants):
|
||||
|
||||
def process_page_reimbursements(exhibit: Exhibit, page_num: str, text_dict: dict[str, str],
|
||||
exhibit_page_nums: list[str], constants: Constants, filename: str):
|
||||
"""
|
||||
STEP 1 HELPER: Extract reimbursements from a single page (without exhibit-level fields).
|
||||
Runs: reimbursement_level → carveout_and_special_case → lesser_of_distribution → breakout
|
||||
"""
|
||||
page_text = text_dict[page_num]
|
||||
exhibit_text_simplified = preprocessing_funcs.simplify_exhibit(text_dict, exhibit_page_nums, page_num)
|
||||
|
||||
@@ -282,34 +198,153 @@ def process_page_one_to_n(text_dict: dict[str, str], exhibit_page_nums: list[str
|
||||
constants,
|
||||
filename
|
||||
)
|
||||
|
||||
|
||||
if not reimbursement_level_answers:
|
||||
return [], [], first_reimbursement_page
|
||||
|
||||
# Track the first page with reimbursements
|
||||
if first_reimbursement_page == '1':
|
||||
first_reimbursement_page = exhibit_page
|
||||
|
||||
logging.debug(f"Reimbursement Primary Answers for {filename}, Page {page_num}: {reimbursement_level_answers}")
|
||||
return [], []
|
||||
|
||||
################################ Carveouts and Special Case ################################
|
||||
reimbursement_level_answers, special_case_answers = one_to_n_funcs.carveout_and_special_case(
|
||||
reimbursement_level_answers, constants, filename
|
||||
) # Breaks the reimbursement answers into reimbursments (regular lines) and special cases (distributed across exhibit)
|
||||
|
||||
|
||||
############################### Dynamic Assignment ###############################
|
||||
reimbursement_level_answers = dynamic_funcs.dynamic_assignment(reimbursement_level_answers, dynamic_reimbursement_fields, exhibit_text_simplified, page_num, constants, filename)
|
||||
)
|
||||
|
||||
############################### Lesser of Distribution ###############################
|
||||
reimbursement_level_answers = one_to_n_funcs.lesser_of_distribution(reimbursement_level_answers, exhibit_text_simplified, page_num, constants, filename)
|
||||
|
||||
# Note: We run lesser_of without dynamic fields in Step 1
|
||||
reimbursement_level_answers = one_to_n_funcs.lesser_of_distribution(
|
||||
reimbursement_level_answers, exhibit_text_simplified, page_num, constants, filename
|
||||
)
|
||||
|
||||
################################ Get Breakouts ###############################
|
||||
reimbursement_level_answers, special_case_answers = one_to_n_funcs.breakout(
|
||||
reimbursement_level_answers,
|
||||
special_case_answers,
|
||||
filename,
|
||||
constants,
|
||||
) # list[dict[str, str]], list[dict[str, str]]
|
||||
)
|
||||
|
||||
################################ Assign REIMB_PAGE ###############################
|
||||
for answer_dict in reimbursement_level_answers:
|
||||
answer_dict["REIMB_PAGE"] = page_num
|
||||
|
||||
return reimbursement_level_answers, special_case_answers
|
||||
|
||||
|
||||
def run_one_to_n_prompts(text_dict: dict[str, str],
|
||||
exhibit_chunk_mapping: dict[str, list[str]],
|
||||
all_exhibit_headers: dict[str, str],
|
||||
constants: Constants,
|
||||
filename: str):
|
||||
"""
|
||||
3-STEP APPROACH (per exhibit):
|
||||
Step 1: Extract reimbursements for exhibit pages (parallel within exhibit)
|
||||
Step 2: Run exhibit_level when exhibit has reimbursements
|
||||
Step 3: Run dynamic_assignment and combine results
|
||||
"""
|
||||
|
||||
total_exhibits = len(exhibit_chunk_mapping)
|
||||
logging.debug(f"{datetime_str()} Processing {total_exhibits} exhibits with NEW 3-step approach - {filename}")
|
||||
|
||||
# Create Exhibit objects in order (maintains exhibit chain via prev_exhibit)
|
||||
exhibits = exhibit_funcs.get_exhibit_list(
|
||||
text_dict,
|
||||
exhibit_chunk_mapping,
|
||||
all_exhibit_headers
|
||||
) # <- Returns list[Exhibit]
|
||||
|
||||
return reimbursement_level_answers, special_case_answers, first_reimbursement_page
|
||||
# Process exhibits serially; within each exhibit, process pages in parallel
|
||||
total_pages = sum(len(exhibit.exhibit_page_nums) for exhibit in exhibits)
|
||||
completed_pages = 0
|
||||
one_to_n_results = []
|
||||
first_reimbursement_page = '1'
|
||||
|
||||
for exhibit in exhibits:
|
||||
exhibit_pages = exhibit.exhibit_page_nums
|
||||
if exhibit_pages:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(exhibit_pages), 20)) as executor:
|
||||
page_futures = {
|
||||
executor.submit(
|
||||
process_page_reimbursements,
|
||||
exhibit, page_num, text_dict, exhibit_pages, constants, filename
|
||||
): page_num
|
||||
for page_num in exhibit_pages
|
||||
}
|
||||
|
||||
for future in concurrent.futures.as_completed(page_futures):
|
||||
try:
|
||||
page_num = page_futures[future]
|
||||
reimbursement_rows, special_case_rows = future.result()
|
||||
exhibit.add_reimbursement_rows(reimbursement_rows, special_case_rows)
|
||||
completed_pages += 1
|
||||
if completed_pages % 20 == 0 or completed_pages == total_pages:
|
||||
logging.debug(f"{datetime_str()} Page progress: {completed_pages}/{total_pages} - {filename}")
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing page: {str(e)}")
|
||||
|
||||
if not exhibit.has_reimbursements:
|
||||
continue
|
||||
|
||||
# STEP 2: exhibit_level for this exhibit
|
||||
exhibit_level_answers, dynamic_reimbursement_fields = one_to_n_funcs.exhibit_level(
|
||||
exhibit.exhibit_text,
|
||||
exhibit.exhibit_header,
|
||||
exhibit.exhibit_page,
|
||||
constants,
|
||||
filename,
|
||||
)
|
||||
exhibit.set_exhibit_level_data(exhibit_level_answers, dynamic_reimbursement_fields)
|
||||
logging.debug(f"Exhibit Level Answers for {filename}, Page {exhibit.exhibit_page}: {exhibit_level_answers}")
|
||||
|
||||
# STEP 3: dynamic assignment & combine for this exhibit
|
||||
# Get dynamic fields from previous exhibit if needed (for future use)
|
||||
if exhibit.dynamic_reimbursement_fields:
|
||||
dynamic_fields = exhibit.dynamic_reimbursement_fields
|
||||
elif exhibit.prev_exhibit and not exhibit.prev_exhibit.has_reimbursements and exhibit.get_previous_exhibit_dynamic_fields().fields:
|
||||
dynamic_fields = exhibit.get_previous_exhibit_dynamic_fields()
|
||||
else:
|
||||
dynamic_fields = None
|
||||
|
||||
# Run dynamic assignment for ALL reimbursement rows in this exhibit
|
||||
# Note: dynamic_assignment expects a list of rows and returns a list of rows
|
||||
reimbursement_rows_with_dynamic = exhibit.reimbursement_rows
|
||||
if exhibit.reimbursement_rows and dynamic_fields is not None:
|
||||
reimbursement_rows_with_dynamic = dynamic_funcs.dynamic_assignment(
|
||||
exhibit.reimbursement_rows,
|
||||
dynamic_fields,
|
||||
text_dict,
|
||||
exhibit,
|
||||
constants,
|
||||
filename
|
||||
)
|
||||
|
||||
# Combine reimbursement rows with exhibit-level answers
|
||||
combined_rows = row_funcs.combine_one_to_n(
|
||||
exhibit.exhibit_text,
|
||||
reimbursement_rows_with_dynamic,
|
||||
exhibit.special_case_rows,
|
||||
exhibit.exhibit_level_answers,
|
||||
filename
|
||||
)
|
||||
|
||||
# Run cleaning
|
||||
combined_rows = one_to_n_funcs.one_to_n_cleaning(
|
||||
combined_rows, exhibit.exhibit_text, constants, filename
|
||||
)
|
||||
|
||||
exhibit.final_rows = combined_rows
|
||||
one_to_n_results.extend(combined_rows)
|
||||
|
||||
# Track first reimbursement page
|
||||
if first_reimbursement_page == '1' and exhibit.has_reimbursements:
|
||||
first_reimbursement_page = exhibit.exhibit_page
|
||||
|
||||
logging.debug(f"{datetime_str()} STEP 3 COMPLETE: Combined {len(one_to_n_results)} total rows - {filename}")
|
||||
|
||||
################## Add N/A Dynamic or Exhibit to One-to-One ##################
|
||||
dynamic_one_to_one_fields = dynamic_funcs.get_dynamic_one_to_one_fields(
|
||||
one_to_n_results, constants
|
||||
)
|
||||
|
||||
|
||||
################## CONVERT TO DF ##################
|
||||
one_to_n_df = pd.DataFrame(one_to_n_results)
|
||||
|
||||
return one_to_n_df, dynamic_one_to_one_fields, first_reimbursement_page
|
||||
|
||||
@@ -5,6 +5,7 @@ This module provides RAG (Retrieval-Augmented Generation) based field extraction
|
||||
for contract processing using hybrid retrieval (BM25 + semantic search) with reranking.
|
||||
"""
|
||||
|
||||
import concurrent.futures
|
||||
import gc
|
||||
import logging
|
||||
import re
|
||||
@@ -15,6 +16,7 @@ from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
import src.utils.llm_utils as llm_utils
|
||||
import src.utils.string_utils as string_utils
|
||||
import src.utils.timing_utils as timing_utils
|
||||
import src.investment.preprocessing_funcs as preprocessing_funcs
|
||||
import src.investment.hybrid_smart_chunking_preprocessing as hybrid_smart_chunking_preprocessing
|
||||
from constants.constants import Constants
|
||||
@@ -35,6 +37,84 @@ from src.utils.rag_utils import (
|
||||
)
|
||||
|
||||
|
||||
def process_single_field(field, retriever, reranker, rag_config, text_dict, constants, filename):
|
||||
"""Process a single field in parallel for HSC."""
|
||||
try:
|
||||
retrieval_query = field.retrieval_question
|
||||
|
||||
if not retrieval_query:
|
||||
return (field.field_name, "N/A", field)
|
||||
|
||||
# Retrieve + rerank using retrieval question
|
||||
with timing_utils.timed_block(f"hsc.field.{field.field_name}.retrieval", context=filename):
|
||||
retrieved = retriever.invoke(retrieval_query)[: rag_config.ENSEMBLE_TOP_K]
|
||||
reranked = rerank_documents(
|
||||
retrieval_query,
|
||||
retrieved,
|
||||
reranker,
|
||||
rag_config.RERANKER_TOP_K,
|
||||
rag_config.RERANKER,
|
||||
)
|
||||
|
||||
# Build context and use actual LLM prompt for extraction
|
||||
context = format_chunks_for_llm(text_dict, reranked)
|
||||
|
||||
if len(context) == 0:
|
||||
# Fallback: mark field for full_context processing
|
||||
field.field_type = "full_context"
|
||||
logging.warning(
|
||||
f"No context retrieved for {field.field_name}, marking as full_context"
|
||||
)
|
||||
return (field.field_name, "N/A", field)
|
||||
|
||||
llm_prompt = field.get_prompt_dict(constants) # Returns dict of {field_name : prompt}
|
||||
prompt = prompt_templates.ONE_TO_ONE_SINGLE_FIELD_TEMPLATE(context, llm_prompt)
|
||||
|
||||
logging.debug(f"Field: {field.field_name}")
|
||||
logging.debug(f"Retrieval question: {retrieval_query}")
|
||||
logging.debug(f"LLM prompt: {llm_prompt}")
|
||||
|
||||
# LLM call
|
||||
with timing_utils.timed_block(f"hsc.field.{field.field_name}.llm_call", context=filename):
|
||||
raw = llm_utils.invoke_claude(
|
||||
prompt, "sonnet_latest", filename, max_tokens=8192
|
||||
)
|
||||
try:
|
||||
parsed = string_utils.universal_json_load(raw) # returns dict
|
||||
|
||||
val = parsed.get(field.field_name)
|
||||
if isinstance(val, list):
|
||||
parsed[field.field_name] = "|".join(map(str, val))
|
||||
elif isinstance(val, str):
|
||||
parsed[field.field_name] = val
|
||||
field_value = parsed[field.field_name]
|
||||
|
||||
if field_value == "N/A":
|
||||
field.field_type = "full_context"
|
||||
if field.field_name == "PAYER_NAME":
|
||||
field.prompt = field.prompt + prompt_templates.FULL_CONTEXT_PAYER_NAME_ADDITIONAL_INSTRUCTION()
|
||||
logging.warning(
|
||||
f"Obtained N/A for {field.field_name}, marking as full_context"
|
||||
)
|
||||
|
||||
return (field.field_name, field_value, field)
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Failed to parse JSON response for field {field.field_name}: "
|
||||
f"{type(e).__name__}: {str(e)}. Setting field value to N/A."
|
||||
)
|
||||
return (field.field_name, "N/A", field)
|
||||
|
||||
except Exception as e:
|
||||
# Mark field for full_context retry and preserve error info
|
||||
field.field_type = "full_context"
|
||||
logging.error(
|
||||
f"Error on {field.field_name}: {type(e).__name__}: {str(e)}, marking as full_context"
|
||||
)
|
||||
return (field.field_name, "N/A", field)
|
||||
|
||||
|
||||
def clip_context_by_tokens(context: str, max_tokens: int = 150000) -> str:
|
||||
"""
|
||||
Clip context text if it exceeds max_tokens.
|
||||
@@ -184,112 +264,48 @@ def run_hybrid_smart_chunked_fields(
|
||||
|
||||
try:
|
||||
# Setup: clients, splitter, chunks
|
||||
reranker = get_reranker_client(rag_config.RERANKER)
|
||||
embeddings = get_embeddings(
|
||||
rag_config.EMBEDDING_MODEL_ID, rag_config.BEDROCK_REGION
|
||||
)
|
||||
splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=rag_config.CHUNK_SIZE,
|
||||
chunk_overlap=rag_config.CHUNK_OVERLAP,
|
||||
add_start_index=True,
|
||||
strip_whitespace=True,
|
||||
separators=["\n\n", "\n", " ", ""],
|
||||
)
|
||||
with timing_utils.timed_block("hsc.setup", context=filename):
|
||||
reranker = get_reranker_client(rag_config.RERANKER)
|
||||
embeddings = get_embeddings(
|
||||
rag_config.EMBEDDING_MODEL_ID, rag_config.BEDROCK_REGION
|
||||
)
|
||||
splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=rag_config.CHUNK_SIZE,
|
||||
chunk_overlap=rag_config.CHUNK_OVERLAP,
|
||||
add_start_index=True,
|
||||
strip_whitespace=True,
|
||||
separators=["\n\n", "\n", " ", ""],
|
||||
)
|
||||
|
||||
sections, is_preserved = preprocessing_pipeline(contract_text)
|
||||
docs = prepare_documents_for_chunking(sections, filename)
|
||||
chunks = splitter.split_documents(docs)
|
||||
del docs
|
||||
gc.collect()
|
||||
with timing_utils.timed_block("hsc.preprocessing", context=filename):
|
||||
sections, is_preserved = preprocessing_pipeline(contract_text)
|
||||
docs = prepare_documents_for_chunking(sections, filename)
|
||||
chunks = splitter.split_documents(docs)
|
||||
del docs
|
||||
gc.collect()
|
||||
|
||||
if not chunks:
|
||||
return {}
|
||||
|
||||
# Build retriever
|
||||
vectorstore = create_vectorstore(chunks, embeddings)
|
||||
retriever = create_hybrid_retriever(chunks, vectorstore, rag_config)
|
||||
with timing_utils.timed_block("hsc.vectorstore_creation", context=filename):
|
||||
vectorstore = create_vectorstore(chunks, embeddings)
|
||||
retriever = create_hybrid_retriever(chunks, vectorstore, rag_config)
|
||||
|
||||
# Process fields
|
||||
answers_dict = {}
|
||||
for field in fields_to_process:
|
||||
try:
|
||||
# Get retrieval question from field attribute
|
||||
retrieval_query = field.retrieval_question
|
||||
|
||||
if not retrieval_query:
|
||||
continue
|
||||
# Retrieve + rerank using retrieval question
|
||||
retrieved = retriever.invoke(retrieval_query)[
|
||||
: rag_config.ENSEMBLE_TOP_K
|
||||
with timing_utils.timed_block("hsc.parallel_field_processing", context=filename):
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(fields_to_process), 20)) as executor:
|
||||
futures = [
|
||||
executor.submit(process_single_field, field, retriever, reranker, rag_config, text_dict, constants, filename)
|
||||
for field in fields_to_process
|
||||
]
|
||||
reranked = rerank_documents(
|
||||
retrieval_query,
|
||||
retrieved,
|
||||
reranker,
|
||||
rag_config.RERANKER_TOP_K,
|
||||
rag_config.RERANKER,
|
||||
)
|
||||
|
||||
# Build context and use actual LLM prompt for extraction
|
||||
context = format_chunks_for_llm(text_dict, reranked)
|
||||
|
||||
if len(context) == 0:
|
||||
# Fallback: mark field for full_context processing
|
||||
field.field_type = "full_context"
|
||||
logging.warning(
|
||||
f"No context retrieved for {field.field_name}, marking as full_context"
|
||||
)
|
||||
continue
|
||||
|
||||
llm_prompt = field.get_prompt_dict(
|
||||
constants
|
||||
) # Returns dict of {field_name : prompt}
|
||||
prompt = prompt_templates.ONE_TO_ONE_SINGLE_FIELD_TEMPLATE(
|
||||
context, llm_prompt
|
||||
)
|
||||
|
||||
logging.debug(f"Field: {field.field_name}")
|
||||
logging.debug(f"Retrieval question: {retrieval_query}")
|
||||
logging.debug(f"LLM prompt: {llm_prompt}")
|
||||
|
||||
# LLM call
|
||||
raw = llm_utils.invoke_claude(
|
||||
prompt, "sonnet_latest", filename, max_tokens=8192
|
||||
)
|
||||
logging.debug(f"Hybrid Smart Chunking RAG Raw response for {field.field_name}: {raw}")
|
||||
try:
|
||||
parsed = string_utils.universal_json_load(
|
||||
raw
|
||||
) # returns dict
|
||||
|
||||
val = parsed.get(field.field_name)
|
||||
if isinstance(val, list):
|
||||
parsed[field.field_name] = "|".join(map(str, val))
|
||||
elif isinstance(val, str):
|
||||
parsed[field.field_name] = val
|
||||
answers_dict[field.field_name] = parsed[field.field_name]
|
||||
|
||||
if answers_dict[field.field_name] == "N/A":
|
||||
field.field_type = "full_context"
|
||||
if field.field_name == "PAYER_NAME":
|
||||
field.prompt = field.prompt + prompt_templates.FULL_CONTEXT_PAYER_NAME_ADDITIONAL_INSTRUCTION()
|
||||
logging.warning(
|
||||
f"Obtained N/A for {field.field_name}, marking as full_context"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Failed to parse JSON response for field {field.field_name}: "
|
||||
f"{type(e).__name__}: {str(e)}. Setting field value to N/A."
|
||||
)
|
||||
answers_dict[field.field_name] = "N/A"
|
||||
|
||||
except Exception as e:
|
||||
# Mark field for full_context retry and preserve error info
|
||||
field.field_type = "full_context"
|
||||
logging.error(
|
||||
f"Error on {field.field_name}: {type(e).__name__}: {str(e)}, marking as full_context"
|
||||
)
|
||||
answers_dict[field.field_name] = "N/A"
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
field_name, field_value, field = future.result()
|
||||
answers_dict[field_name] = field_value
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing field: {str(e)}")
|
||||
|
||||
del retriever, vectorstore, chunks
|
||||
gc.collect()
|
||||
|
||||
@@ -2,6 +2,7 @@ import concurrent.futures
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
|
||||
@@ -15,6 +16,7 @@ import src.utils.io_utils as io_utils
|
||||
import src.utils.llm_utils as llm_utils
|
||||
import src.utils.logging_utils as logging_utils
|
||||
import src.utils.usage_tracking as usage_tracking
|
||||
import src.utils.timing_utils as timing_utils
|
||||
from constants.constants import Constants
|
||||
from src import config
|
||||
from src.parent_child import main as parent_child_main
|
||||
@@ -84,20 +86,25 @@ def main(testing=False, test_params={}):
|
||||
# Windows paths cannot contain ':'; use '-' in time component
|
||||
run_timestamp = datetime.now().strftime(f"run_%Y%m%d_%H-%M_{config.BATCH_ID}")
|
||||
|
||||
# Track overall pipeline time
|
||||
pipeline_start = time.time()
|
||||
|
||||
# Read Constants
|
||||
constants = Constants()
|
||||
with timing_utils.timed_block("read_constants", log_level="INFO"):
|
||||
constants = Constants()
|
||||
|
||||
# Read and process input
|
||||
if not testing:
|
||||
input_dict = io_utils.read_input()
|
||||
max_workers = config.MAX_WORKERS
|
||||
else:
|
||||
input_dict = io_utils.read_input(test_params["local_input_dir"])
|
||||
max_workers = test_params["max_workers"]
|
||||
if "input_files" in test_params:
|
||||
input_dict = {
|
||||
k: v for k, v in input_dict.items() if k in test_params["input_files"]
|
||||
}
|
||||
with timing_utils.timed_block("read_input", log_level="INFO"):
|
||||
if not testing:
|
||||
input_dict = io_utils.read_input()
|
||||
max_workers = config.MAX_WORKERS
|
||||
else:
|
||||
input_dict = io_utils.read_input(test_params["local_input_dir"])
|
||||
max_workers = test_params["max_workers"]
|
||||
if "input_files" in test_params:
|
||||
input_dict = {
|
||||
k: v for k, v in input_dict.items() if k in test_params["input_files"]
|
||||
}
|
||||
|
||||
logging.info(f"Total Input Files : {len(input_dict)}")
|
||||
|
||||
@@ -117,82 +124,98 @@ def main(testing=False, test_params={}):
|
||||
# Warm prompt caches before parallel processing
|
||||
# This ensures the cache is registered before multiple threads try to use it
|
||||
logging.info("Warming prompt caches before parallel processing...")
|
||||
try:
|
||||
cacheable_instructions = prompt_templates.get_cacheable_instructions(constants)
|
||||
for cache_key, instruction in cacheable_instructions.items():
|
||||
llm_utils.warm_prompt_cache(
|
||||
instruction=instruction,
|
||||
cache_key=cache_key,
|
||||
model_id="sonnet_latest"
|
||||
)
|
||||
logging.info(f"Successfully warmed {len(cacheable_instructions)} prompt caches")
|
||||
except Exception as e:
|
||||
logging.warning(f"Error warming caches (will proceed anyway): {str(e)}")
|
||||
with timing_utils.timed_block("warm_prompt_caches", log_level="INFO"):
|
||||
try:
|
||||
cacheable_instructions = prompt_templates.get_cacheable_instructions(constants)
|
||||
for cache_key, instruction in cacheable_instructions.items():
|
||||
llm_utils.warm_prompt_cache(
|
||||
instruction=instruction,
|
||||
cache_key=cache_key,
|
||||
model_id="sonnet_latest"
|
||||
)
|
||||
logging.info(f"Successfully warmed {len(cacheable_instructions)} prompt caches")
|
||||
except Exception as e:
|
||||
logging.warning(f"Error warming caches (will proceed anyway): {str(e)}")
|
||||
|
||||
# Process files concurrently - each thread gets its own per-file logging context
|
||||
successful_results = []
|
||||
error_results = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
# Each submitted task will set its own logging context in file_processing.process_file()
|
||||
# We use an executor to process files concurrently; use submit() instead of map()
|
||||
# so that we can catch exceptions and continue processing other files
|
||||
futures = [
|
||||
executor.submit(safe_process_file, item, constants, run_timestamp)
|
||||
for item in input_dict.items()
|
||||
]
|
||||
|
||||
# collect results as they complete
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
results = future.result()
|
||||
if "error" in results.columns:
|
||||
error_results.append(results)
|
||||
else:
|
||||
successful_results.append(results)
|
||||
except (
|
||||
Exception
|
||||
) as e: # When there's an issue with the future itself (not the processing inside the future)
|
||||
logging.error(f"Error processing future: {str(e)}")
|
||||
# Coerce this to the format expected by pd.concat (so a single-row dataframe)
|
||||
error_results.append(pd.DataFrame([{"error": str(e)}]))
|
||||
logging.info(f"Starting parallel file processing with {max_workers} workers...")
|
||||
with timing_utils.timed_block("parallel_file_processing", log_level="INFO"):
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
# Each submitted task will set its own logging context in file_processing.process_file()
|
||||
# We use an executor to process files concurrently; use submit() instead of map()
|
||||
# so that we can catch exceptions and continue processing other files
|
||||
futures = [
|
||||
executor.submit(safe_process_file, item, constants, run_timestamp)
|
||||
for item in input_dict.items()
|
||||
]
|
||||
|
||||
# collect results as they complete
|
||||
completed_count = 0
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
results = future.result()
|
||||
if "error" in results.columns:
|
||||
error_results.append(results)
|
||||
else:
|
||||
successful_results.append(results)
|
||||
|
||||
completed_count += 1
|
||||
if completed_count % 5 == 0: # Log progress every 5 files
|
||||
logging.info(f"Processed {completed_count}/{len(futures)} files...")
|
||||
except (
|
||||
Exception
|
||||
) as e: # When there's an issue with the future itself (not the processing inside the future)
|
||||
logging.error(f"Error processing future: {str(e)}")
|
||||
# Coerce this to the format expected by pd.concat (so a single-row dataframe)
|
||||
error_results.append(pd.DataFrame([{"error": str(e)}]))
|
||||
|
||||
# only concat if we have results
|
||||
if len(successful_results) > 0:
|
||||
FINAL_RESULT_DF = pd.concat(successful_results)
|
||||
else:
|
||||
FINAL_RESULT_DF = pd.DataFrame()
|
||||
with timing_utils.timed_block("concat_results", log_level="INFO"):
|
||||
if len(successful_results) > 0:
|
||||
FINAL_RESULT_DF = pd.concat(successful_results, ignore_index=True)
|
||||
else:
|
||||
FINAL_RESULT_DF = pd.DataFrame()
|
||||
|
||||
if len(error_results) > 0:
|
||||
ERROR_RESULT_DF = pd.concat(error_results)
|
||||
else:
|
||||
ERROR_RESULT_DF = pd.DataFrame()
|
||||
if len(error_results) > 0:
|
||||
ERROR_RESULT_DF = pd.concat(error_results, ignore_index=True)
|
||||
else:
|
||||
ERROR_RESULT_DF = pd.DataFrame()
|
||||
|
||||
# Run QC/QA validation if enabled (creates separate validated copy)
|
||||
if config.ENABLE_QC_QA and not FINAL_RESULT_DF.empty:
|
||||
logging.info("=" * 80)
|
||||
logging.info("Running QC/QA Validation Pipeline...")
|
||||
logging.info("=" * 80)
|
||||
try:
|
||||
# Create a copy for validation - preserve original FINAL_RESULT_DF
|
||||
validated_df = run_qc_qa_pipeline(FINAL_RESULT_DF.copy(), stats_df=None)
|
||||
logging.info("QC/QA validation completed successfully")
|
||||
with timing_utils.timed_block("qc_qa_validation", log_level="INFO"):
|
||||
try:
|
||||
# Ensure unique index before QC/QA (safety check)
|
||||
if FINAL_RESULT_DF.index.has_duplicates:
|
||||
logging.warning("Detected duplicate indices in FINAL_RESULT_DF, resetting index...")
|
||||
FINAL_RESULT_DF = FINAL_RESULT_DF.reset_index(drop=True)
|
||||
|
||||
# Generate validation statistics
|
||||
logging.info("Generating QC/QA statistics...")
|
||||
qc_qa_stats_df = generate_statistics(validated_df)
|
||||
logging.info("QC/QA statistics generated successfully")
|
||||
# Create a copy for validation - preserve original FINAL_RESULT_DF
|
||||
validated_df = run_qc_qa_pipeline(FINAL_RESULT_DF.copy(), stats_df=None)
|
||||
logging.info("QC/QA validation completed successfully")
|
||||
|
||||
# Save QC/QA outputs (local + S3) - separate from main results
|
||||
save_qc_qa_outputs(
|
||||
validated_df=validated_df,
|
||||
stats_df=qc_qa_stats_df,
|
||||
run_timestamp=run_timestamp,
|
||||
write_to_s3=config.WRITE_TO_S3
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"QC/QA validation failed: {str(e)}")
|
||||
logging.error(f"Full traceback:\n{traceback.format_exc()}")
|
||||
logging.warning("Continuing with original unvalidated results...")
|
||||
# Generate validation statistics
|
||||
logging.info("Generating QC/QA statistics...")
|
||||
qc_qa_stats_df = generate_statistics(validated_df)
|
||||
logging.info("QC/QA statistics generated successfully")
|
||||
|
||||
# Save QC/QA outputs (local + S3) - separate from main results
|
||||
save_qc_qa_outputs(
|
||||
validated_df=validated_df,
|
||||
stats_df=qc_qa_stats_df,
|
||||
run_timestamp=run_timestamp,
|
||||
write_to_s3=config.WRITE_TO_S3
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"QC/QA validation failed: {str(e)}")
|
||||
logging.error(f"Full traceback:\n{traceback.format_exc()}")
|
||||
logging.warning("Continuing with original unvalidated results...")
|
||||
logging.info("=" * 80)
|
||||
|
||||
if not testing:
|
||||
@@ -201,29 +224,38 @@ def main(testing=False, test_params={}):
|
||||
config.BATCH_ID, run_timestamp
|
||||
)
|
||||
|
||||
if config.WRITE_TO_S3:
|
||||
doczy_output_for_pc = io_utils.write_s3(FINAL_RESULT_DF, "", run_timestamp, "final")
|
||||
if not ERROR_RESULT_DF.empty:
|
||||
doczy_output_for_pc = io_utils.write_s3(ERROR_RESULT_DF, "", run_timestamp, "error")
|
||||
with timing_utils.timed_block("write_outputs", log_level="INFO"):
|
||||
if config.WRITE_TO_S3:
|
||||
doczy_output_for_pc = io_utils.write_s3(FINAL_RESULT_DF, "", run_timestamp, "final")
|
||||
if not ERROR_RESULT_DF.empty:
|
||||
doczy_output_for_pc = io_utils.write_s3(ERROR_RESULT_DF, "", run_timestamp, "error")
|
||||
|
||||
# Export usage and cost tracking data to S3 only if both dataframes have data
|
||||
if not per_file_usage_df.empty and not batch_summary_df.empty:
|
||||
logging.info("Exporting usage and cost tracking data to S3...")
|
||||
io_utils.write_s3(per_file_usage_df, "", run_timestamp, "usage")
|
||||
io_utils.write_s3(batch_summary_df, "", run_timestamp, "usage_summary")
|
||||
else:
|
||||
doczy_output_for_pc = io_utils.write_local(FINAL_RESULT_DF, "", run_timestamp, "final")
|
||||
if not ERROR_RESULT_DF.empty:
|
||||
doczy_output_for_pc = io_utils.write_local(ERROR_RESULT_DF, "", run_timestamp, "error")
|
||||
|
||||
# Export usage and cost tracking data to S3 only if both dataframes have data
|
||||
if not per_file_usage_df.empty and not batch_summary_df.empty:
|
||||
logging.info("Exporting usage and cost tracking data to S3...")
|
||||
io_utils.write_s3(per_file_usage_df, "", run_timestamp, "usage")
|
||||
io_utils.write_s3(batch_summary_df, "", run_timestamp, "usage_summary")
|
||||
else:
|
||||
doczy_output_for_pc = io_utils.write_local(FINAL_RESULT_DF, "", run_timestamp, "final")
|
||||
if not ERROR_RESULT_DF.empty:
|
||||
doczy_output_for_pc = io_utils.write_local(ERROR_RESULT_DF, "", run_timestamp, "error")
|
||||
|
||||
if config.PERFORM_PARENT_CHILD_MAPPING:
|
||||
parent_child_main.main(doczy_output_for_pc)
|
||||
with timing_utils.timed_block("parent_child_mapping", log_level="INFO"):
|
||||
parent_child_main.main(doczy_output_for_pc)
|
||||
|
||||
# Export usage and cost tracking data locally only if both dataframes have data
|
||||
if not per_file_usage_df.empty and not batch_summary_df.empty:
|
||||
logging.info("Exporting usage and cost tracking data locally...")
|
||||
io_utils.write_local(per_file_usage_df, "", run_timestamp, "usage")
|
||||
io_utils.write_local(batch_summary_df, "", run_timestamp, "usage_summary")
|
||||
|
||||
# Print timing summary
|
||||
pipeline_duration = time.time() - pipeline_start
|
||||
logging.info("=" * 80)
|
||||
logging.info(f"PIPELINE COMPLETE - Total time: {pipeline_duration:.2f}s")
|
||||
logging.info("=" * 80)
|
||||
timing_utils.print_hierarchical_timing_summary()
|
||||
else:
|
||||
return FINAL_RESULT_DF, ERROR_RESULT_DF
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import concurrent.futures
|
||||
import logging
|
||||
|
||||
from src.investment import dynamic_funcs, prompt_calls, aarete_derived, postprocessing_funcs
|
||||
@@ -167,6 +168,48 @@ def breakout(
|
||||
return reimbursement_level_answers, special_case_answers
|
||||
|
||||
|
||||
def process_single_carveout(answer_dict, carveout_definitions, special_case_definitions, filename):
|
||||
"""Process a single carveout/special case in parallel"""
|
||||
service_term, reimb_term = answer_dict.get("SERVICE_TERM"), answer_dict.get("REIMB_TERM")
|
||||
|
||||
reimbursement_categorization = prompt_calls.prompt_carveout_check(
|
||||
service_term, reimb_term, carveout_definitions, special_case_definitions, filename
|
||||
)
|
||||
|
||||
if reimbursement_categorization in carveout_definitions.keys():
|
||||
answer_dict["CARVEOUT_CD"] = reimbursement_categorization
|
||||
# Standard N/A OR Base response gets CARVEOUT_IND=N
|
||||
if reimbursement_categorization in ["N/A", "BASE_COVERED_SERVICES", "DEFAULT_TERM"]:
|
||||
answer_dict["CARVEOUT_IND"] = "N"
|
||||
else:
|
||||
answer_dict["CARVEOUT_IND"] = "Y"
|
||||
# Determine DEFAULT_IND based on specific carveout_answer values
|
||||
answer_dict["DEFAULT_IND"] = (
|
||||
"Y" if reimbursement_categorization in ["DEFAULT_TERM", "UNLISTED_CODE"] else "N"
|
||||
)
|
||||
|
||||
# Carveout breakouts
|
||||
if reimbursement_categorization == "TRIGGER_CAP":
|
||||
breakout_answer_dict = prompt_calls.prompt_special_case_breakout(
|
||||
prompt_templates.TRIGGER_CAP_BREAKOUT, reimb_term, filename
|
||||
)
|
||||
answer_dict.update(breakout_answer_dict)
|
||||
if reimbursement_categorization == "ADDITION":
|
||||
breakout_answer_dict = prompt_calls.prompt_special_case_breakout(
|
||||
prompt_templates.ADDITION_BREAKOUT, reimb_term, filename
|
||||
)
|
||||
answer_dict.update(breakout_answer_dict)
|
||||
|
||||
return ("reimbursement", answer_dict)
|
||||
elif reimbursement_categorization in special_case_definitions.keys():
|
||||
answer_dict[reimbursement_categorization] = reimb_term
|
||||
answer_dict.pop("REIMB_TERM", None)
|
||||
answer_dict.pop("SERVICE_TERM", None)
|
||||
return ("special_case", answer_dict)
|
||||
else:
|
||||
return (None, answer_dict)
|
||||
|
||||
|
||||
def carveout_and_special_case(
|
||||
reimbursement_breakout_answers: list[dict], constants: Constants, filename: str
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
@@ -185,43 +228,23 @@ def carveout_and_special_case(
|
||||
|
||||
reimbursement_level_answers, special_case_answers = [], []
|
||||
|
||||
for answer_dict in reimbursement_breakout_answers:
|
||||
service_term, reimb_term = answer_dict.get("SERVICE_TERM"), answer_dict.get("REIMB_TERM")
|
||||
|
||||
reimbursement_categorization = prompt_calls.prompt_carveout_check(
|
||||
service_term, reimb_term, carveout_definitions, special_case_definitions, filename
|
||||
)
|
||||
|
||||
if reimbursement_categorization in carveout_definitions.keys():
|
||||
answer_dict["CARVEOUT_CD"] = reimbursement_categorization
|
||||
# Standard N/A OR Base response gets CARVEOUT_IND=N
|
||||
if reimbursement_categorization in ["N/A", "BASE_COVERED_SERVICES", "DEFAULT_TERM"]:
|
||||
answer_dict["CARVEOUT_IND"] = "N"
|
||||
else:
|
||||
answer_dict["CARVEOUT_IND"] = "Y"
|
||||
# Determine DEFAULT_IND based on specific carveout_answer values
|
||||
answer_dict["DEFAULT_IND"] = (
|
||||
"Y" if reimbursement_categorization in ["DEFAULT_TERM", "UNLISTED_CODE"] else "N"
|
||||
)
|
||||
# Parallelize carveout processing
|
||||
if reimbursement_breakout_answers:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(reimbursement_breakout_answers), 5)) as executor:
|
||||
futures = [
|
||||
executor.submit(process_single_carveout, answer_dict.copy(), carveout_definitions, special_case_definitions, filename)
|
||||
for answer_dict in reimbursement_breakout_answers
|
||||
]
|
||||
|
||||
# Carveout breakouts
|
||||
if reimbursement_categorization == "TRIGGER_CAP":
|
||||
breakout_answer_dict = prompt_calls.prompt_special_case_breakout(
|
||||
prompt_templates.TRIGGER_CAP_BREAKOUT, reimb_term, filename
|
||||
)
|
||||
answer_dict.update(breakout_answer_dict)
|
||||
if reimbursement_categorization == "ADDITION":
|
||||
breakout_answer_dict = prompt_calls.prompt_special_case_breakout(
|
||||
prompt_templates.ADDITION_BREAKOUT, reimb_term, filename
|
||||
)
|
||||
answer_dict.update(breakout_answer_dict)
|
||||
|
||||
reimbursement_level_answers.append(answer_dict)
|
||||
elif reimbursement_categorization in special_case_definitions.keys():
|
||||
answer_dict[reimbursement_categorization] = reimb_term
|
||||
answer_dict.pop("REIMB_TERM", None)
|
||||
answer_dict.pop("SERVICE_TERM", None)
|
||||
special_case_answers.append(answer_dict)
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
result_type, result_dict = future.result()
|
||||
if result_type == "reimbursement":
|
||||
reimbursement_level_answers.append(result_dict)
|
||||
elif result_type == "special_case":
|
||||
special_case_answers.append(result_dict)
|
||||
except Exception as e:
|
||||
logging.error(f"Error in carveout processing: {str(e)}")
|
||||
|
||||
return reimbursement_level_answers, special_case_answers
|
||||
|
||||
@@ -245,10 +268,21 @@ def methodology_breakout(
|
||||
"""
|
||||
|
||||
final_answers = []
|
||||
for answer_dict in reimbursement_primary_answers:
|
||||
final_answers += methodology_breakout_single_row(
|
||||
answer_dict, constants, filename
|
||||
)
|
||||
|
||||
# Parallelize methodology breakout processing
|
||||
if reimbursement_primary_answers:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(reimbursement_primary_answers), 5)) as executor:
|
||||
futures = [
|
||||
executor.submit(methodology_breakout_single_row, answer_dict, constants, filename)
|
||||
for answer_dict in reimbursement_primary_answers
|
||||
]
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
final_answers += future.result()
|
||||
except Exception as e:
|
||||
logging.error(f"Error in methodology breakout: {str(e)}")
|
||||
|
||||
return final_answers
|
||||
|
||||
|
||||
@@ -398,6 +432,19 @@ def methodology_breakout_secondary(
|
||||
}
|
||||
|
||||
|
||||
def process_single_special_case_breakout(answer_dict, special_case_fields, filename):
|
||||
"""Process a single special case breakout in parallel"""
|
||||
term_field_name = [field_name for field_name in answer_dict.keys() if "_TERM" in field_name and field_name not in ["SERVICE_TERM","REIMB_TERM",]][0]
|
||||
term_answer = answer_dict[term_field_name]
|
||||
term_field = special_case_fields.get_field(term_field_name)
|
||||
breakout_template = term_field.get_breakout_template()
|
||||
breakout_answer_dict = prompt_calls.prompt_special_case_breakout(
|
||||
breakout_template, term_answer, filename
|
||||
)
|
||||
answer_dict.update(breakout_answer_dict)
|
||||
return answer_dict
|
||||
|
||||
|
||||
def special_case_breakout(
|
||||
special_case_answers: list[dict],
|
||||
filename: str,
|
||||
@@ -423,18 +470,36 @@ def special_case_breakout(
|
||||
dict[str, list]: A dictionary containing the processed breakout answers for each
|
||||
special case field.
|
||||
"""
|
||||
if not special_case_answers:
|
||||
return special_case_answers
|
||||
|
||||
special_case_fields = FieldSet(config.FIELD_JSON_PATH, field_type="special_case_check")
|
||||
for answer_dict in special_case_answers:
|
||||
term_field_name = [field_name for field_name in answer_dict.keys() if "_TERM" in field_name and field_name not in ["SERVICE_TERM","REIMB_TERM",]][0]
|
||||
term_answer = answer_dict[term_field_name]
|
||||
term_field = special_case_fields.get_field(term_field_name)
|
||||
breakout_template = term_field.get_breakout_template()
|
||||
breakout_answer_dict = prompt_calls.prompt_special_case_breakout(
|
||||
breakout_template, term_answer, filename
|
||||
)
|
||||
answer_dict.update(breakout_answer_dict)
|
||||
|
||||
return special_case_answers
|
||||
|
||||
# Parallelize special case breakout processing
|
||||
updated_answers = []
|
||||
if special_case_answers:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(special_case_answers), 5)) as executor:
|
||||
futures = [
|
||||
executor.submit(process_single_special_case_breakout, answer_dict.copy(), special_case_fields, filename)
|
||||
for answer_dict in special_case_answers
|
||||
]
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
updated_answers.append(future.result())
|
||||
except Exception as e:
|
||||
logging.error(f"Error in special case breakout: {str(e)}")
|
||||
|
||||
return updated_answers
|
||||
|
||||
|
||||
def validate_single_reimbursement(answer_dict, filename):
|
||||
"""Validate a single reimbursement in parallel"""
|
||||
if prompt_calls.validate_reimbursements_for_llm(answer_dict, filename):
|
||||
return answer_dict
|
||||
else:
|
||||
logging.debug(f"LLM filtered out: {answer_dict}")
|
||||
return None
|
||||
|
||||
|
||||
def filter_services_without_reimbursements(
|
||||
@@ -467,11 +532,29 @@ def filter_services_without_reimbursements(
|
||||
|
||||
valid_reimbursements = []
|
||||
|
||||
for answer_dict in reimbursement_primary_answers:
|
||||
if prompt_calls.validate_reimbursements_for_llm(answer_dict, filename):
|
||||
valid_reimbursements.append(answer_dict)
|
||||
else:
|
||||
logging.debug(f"LLM filtered out: {answer_dict}")
|
||||
# Parallelize validation
|
||||
if reimbursement_primary_answers:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(reimbursement_primary_answers), 5)) as executor:
|
||||
# Create futures with index tracking
|
||||
future_to_index = {
|
||||
executor.submit(validate_single_reimbursement, answer_dict, filename): i
|
||||
for i, answer_dict in enumerate(reimbursement_primary_answers)
|
||||
}
|
||||
|
||||
# Collect results with their original indices
|
||||
results_with_indices = []
|
||||
for future in concurrent.futures.as_completed(future_to_index):
|
||||
try:
|
||||
result = future.result()
|
||||
if result is not None:
|
||||
idx = future_to_index[future]
|
||||
results_with_indices.append((idx, result))
|
||||
except Exception as e:
|
||||
logging.error(f"Error in reimbursement validation: {str(e)}")
|
||||
|
||||
# Sort by original index to preserve order
|
||||
results_with_indices.sort(key=lambda x: x[0])
|
||||
valid_reimbursements = [result for _, result in results_with_indices]
|
||||
|
||||
filtered_count = len(reimbursement_primary_answers) - len(valid_reimbursements)
|
||||
if filtered_count > 0:
|
||||
@@ -583,6 +666,29 @@ def split_reimb_dates(one_to_n_results: list, filename: str) -> list:
|
||||
return one_to_n_results
|
||||
|
||||
|
||||
def process_single_lob_relationship(answer_dict, exhibit_text, filename):
|
||||
"""Process LOB relationship for a single answer dict in parallel"""
|
||||
if string_utils.is_empty(answer_dict.get("AARETE_DERIVED_LOB")):
|
||||
return answer_dict
|
||||
|
||||
# Process for program
|
||||
if not string_utils.is_empty(answer_dict.get("AARETE_DERIVED_PROGRAM")):
|
||||
answer_dict["LOB_PROGRAM_RELATIONSHIP"] = (
|
||||
prompt_calls.prompt_lob_relationship(
|
||||
answer_dict, "AARETE_DERIVED_PROGRAM", exhibit_text, filename
|
||||
)
|
||||
)
|
||||
# Process for Product
|
||||
if not string_utils.is_empty(answer_dict.get("AARETE_DERIVED_PRODUCT")):
|
||||
answer_dict["LOB_PRODUCT_RELATIONSHIP"] = (
|
||||
prompt_calls.prompt_lob_relationship(
|
||||
answer_dict, "AARETE_DERIVED_PRODUCT", exhibit_text, filename
|
||||
)
|
||||
)
|
||||
|
||||
return answer_dict
|
||||
|
||||
|
||||
def get_lob_relationship(answer_dicts, exhibit_text, filename):
|
||||
"""
|
||||
Populates LOB_PROGRAM_RELATIONSHIP and LOB_PRODUCT_RELATIONSHIP fields in answer_dicts
|
||||
@@ -596,25 +702,25 @@ def get_lob_relationship(answer_dicts, exhibit_text, filename):
|
||||
Returns:
|
||||
list[dict]: The updated list of dictionaries with LOB relationships populated.
|
||||
"""
|
||||
for answer_dict in answer_dicts:
|
||||
if string_utils.is_empty(answer_dict.get("AARETE_DERIVED_LOB")):
|
||||
continue
|
||||
# Process for program
|
||||
if not string_utils.is_empty(answer_dict.get("AARETE_DERIVED_PROGRAM")):
|
||||
answer_dict["LOB_PROGRAM_RELATIONSHIP"] = (
|
||||
prompt_calls.prompt_lob_relationship(
|
||||
answer_dict, "AARETE_DERIVED_PROGRAM", exhibit_text, filename
|
||||
)
|
||||
)
|
||||
# Process for Product
|
||||
if not string_utils.is_empty(answer_dict.get("AARETE_DERIVED_PRODUCT")):
|
||||
answer_dict["LOB_PRODUCT_RELATIONSHIP"] = (
|
||||
prompt_calls.prompt_lob_relationship(
|
||||
answer_dict, "AARETE_DERIVED_PRODUCT", exhibit_text, filename
|
||||
)
|
||||
)
|
||||
if not answer_dicts:
|
||||
return answer_dicts
|
||||
|
||||
return answer_dicts
|
||||
# Parallelize LOB relationship processing
|
||||
updated_dicts = []
|
||||
if answer_dicts:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(answer_dicts), 5)) as executor:
|
||||
futures = [
|
||||
executor.submit(process_single_lob_relationship, answer_dict.copy(), exhibit_text, filename)
|
||||
for answer_dict in answer_dicts
|
||||
]
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
updated_dicts.append(future.result())
|
||||
except Exception as e:
|
||||
logging.error(f"Error in LOB relationship processing: {str(e)}")
|
||||
|
||||
return updated_dicts
|
||||
|
||||
def one_to_n_cleaning(all_exhibit_rows: list[dict], exhibit_text: str, constants: Constants, filename: str):
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def main(doczy_output_for_pc):
|
||||
)
|
||||
return mapped_df.shape[0]
|
||||
except Exception as e:
|
||||
logging.info(f"Parent child mapping failed due to {e}")
|
||||
logging.error(f"Parent child mapping failed due to {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -10,7 +10,7 @@ warnings.filterwarnings("ignore")
|
||||
########################## READ AND PREPROCESS ##########################
|
||||
# Read files
|
||||
testbed = pd.read_excel("Doczy-Testbed.xlsx") # This is the testbed
|
||||
results = pd.read_csv("Doczy-Results.csv") # This is the doczy output
|
||||
results = pd.read_excel("Doczy-Results.xlsx") # This is the doczy output
|
||||
|
||||
########################## Preprocess ##########################
|
||||
testbed = testbed_utils.testbed_preprocess(testbed)
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Timing utilities for performance monitoring and bottleneck identification.
|
||||
|
||||
Provides decorators and context managers for tracking execution time of functions and code blocks.
|
||||
Includes thread-safe statistics collection for concurrent operations.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from threading import Lock
|
||||
from typing import Dict, List, Optional, Callable, Any
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimingStats:
|
||||
"""Statistics for a timed operation."""
|
||||
name: str
|
||||
count: int = 0
|
||||
total_time: float = 0.0
|
||||
min_time: float = float('inf')
|
||||
max_time: float = 0.0
|
||||
times: List[float] = field(default_factory=list)
|
||||
|
||||
def add(self, duration: float):
|
||||
"""Add a timing measurement."""
|
||||
self.count += 1
|
||||
self.total_time += duration
|
||||
self.min_time = min(self.min_time, duration)
|
||||
self.max_time = max(self.max_time, duration)
|
||||
self.times.append(duration)
|
||||
|
||||
@property
|
||||
def avg_time(self) -> float:
|
||||
"""Calculate average time."""
|
||||
return self.total_time / self.count if self.count > 0 else 0.0
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Format timing statistics."""
|
||||
if self.count == 0:
|
||||
return f"{self.name}: No data"
|
||||
return (
|
||||
f"{self.name}: "
|
||||
f"count={self.count}, "
|
||||
f"total={self.total_time:.2f}s, "
|
||||
f"avg={self.avg_time:.2f}s, "
|
||||
f"min={self.min_time:.2f}s, "
|
||||
f"max={self.max_time:.2f}s"
|
||||
)
|
||||
|
||||
|
||||
class TimingTracker:
|
||||
"""Thread-safe timing statistics tracker."""
|
||||
|
||||
def __init__(self):
|
||||
self._stats: Dict[str, TimingStats] = {}
|
||||
self._lock = Lock()
|
||||
self._enabled = True
|
||||
|
||||
def record(self, name: str, duration: float, context: Optional[str] = None):
|
||||
"""Record a timing measurement."""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
key = f"{context}.{name}" if context else name
|
||||
|
||||
with self._lock:
|
||||
if key not in self._stats:
|
||||
self._stats[key] = TimingStats(name=key)
|
||||
self._stats[key].add(duration)
|
||||
|
||||
def get_stats(self, name: Optional[str] = None) -> Dict[str, TimingStats]:
|
||||
"""Get timing statistics."""
|
||||
with self._lock:
|
||||
if name:
|
||||
return {k: v for k, v in self._stats.items() if name in k}
|
||||
return dict(self._stats)
|
||||
|
||||
def print_summary(self, min_time: float = 0.0):
|
||||
"""Print timing summary sorted by total time."""
|
||||
with self._lock:
|
||||
if not self._stats:
|
||||
logging.info("No timing data collected")
|
||||
return
|
||||
|
||||
# Filter and sort by total time
|
||||
filtered_stats = [s for s in self._stats.values() if s.total_time >= min_time]
|
||||
sorted_stats = sorted(filtered_stats, key=lambda s: s.total_time, reverse=True)
|
||||
|
||||
logging.info("=" * 80)
|
||||
logging.info("TIMING SUMMARY (sorted by total time)")
|
||||
logging.info("=" * 80)
|
||||
|
||||
for stat in sorted_stats:
|
||||
logging.info(str(stat))
|
||||
|
||||
# Calculate total tracked time
|
||||
total_tracked = sum(s.total_time for s in self._stats.values())
|
||||
logging.info("=" * 80)
|
||||
logging.info(f"Total tracked time: {total_tracked:.2f}s")
|
||||
logging.info("=" * 80)
|
||||
|
||||
def print_hierarchical_summary(self):
|
||||
"""Print timing summary organized hierarchically by component."""
|
||||
with self._lock:
|
||||
if not self._stats:
|
||||
logging.info("No timing data collected")
|
||||
return
|
||||
|
||||
# Group stats by top-level component
|
||||
components = defaultdict(list)
|
||||
for name, stat in self._stats.items():
|
||||
component = name.split('.')[0]
|
||||
components[component].append(stat)
|
||||
|
||||
logging.info("=" * 80)
|
||||
logging.info("HIERARCHICAL TIMING SUMMARY")
|
||||
logging.info("=" * 80)
|
||||
|
||||
# Sort components by total time
|
||||
component_totals = {
|
||||
comp: sum(s.total_time for s in stats)
|
||||
for comp, stats in components.items()
|
||||
}
|
||||
sorted_components = sorted(component_totals.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
for component, total_time in sorted_components:
|
||||
logging.info(f"\n{component}: {total_time:.2f}s total")
|
||||
logging.info("-" * 60)
|
||||
|
||||
# Sort stats within component by total time
|
||||
sorted_stats = sorted(components[component], key=lambda s: s.total_time, reverse=True)
|
||||
for stat in sorted_stats:
|
||||
indent = " " * (stat.name.count('.'))
|
||||
logging.info(f"{indent}{stat}")
|
||||
|
||||
logging.info("=" * 80)
|
||||
|
||||
def reset(self):
|
||||
"""Reset all timing statistics."""
|
||||
with self._lock:
|
||||
self._stats.clear()
|
||||
|
||||
def enable(self):
|
||||
"""Enable timing tracking."""
|
||||
self._enabled = True
|
||||
|
||||
def disable(self):
|
||||
"""Disable timing tracking."""
|
||||
self._enabled = False
|
||||
|
||||
|
||||
# Global timing tracker instance
|
||||
_global_tracker = TimingTracker()
|
||||
|
||||
|
||||
def get_tracker() -> TimingTracker:
|
||||
"""Get the global timing tracker."""
|
||||
return _global_tracker
|
||||
|
||||
|
||||
@contextmanager
|
||||
def timed_block(name: str, context: Optional[str] = None, log_level: str = "DEBUG"):
|
||||
"""
|
||||
Context manager for timing a block of code.
|
||||
|
||||
Usage:
|
||||
with timed_block("database_query", context="user_123"):
|
||||
# code to time
|
||||
result = db.query(...)
|
||||
"""
|
||||
start_time = time.time()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
duration = time.time() - start_time
|
||||
_global_tracker.record(name, duration, context)
|
||||
|
||||
# Also log the timing
|
||||
log_func = getattr(logging, log_level.lower(), logging.debug)
|
||||
full_name = f"{context}.{name}" if context else name
|
||||
log_func(f"⏱️ {full_name}: {duration:.2f}s")
|
||||
|
||||
|
||||
def timed(name: Optional[str] = None, context: Optional[str] = None, log_level: str = "DEBUG"):
|
||||
"""
|
||||
Decorator for timing function execution.
|
||||
|
||||
Usage:
|
||||
@timed("process_document")
|
||||
def process_document(doc):
|
||||
# function code
|
||||
pass
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
func_name = name or func.__name__
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs) -> Any:
|
||||
start_time = time.time()
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
return result
|
||||
finally:
|
||||
duration = time.time() - start_time
|
||||
_global_tracker.record(func_name, duration, context)
|
||||
|
||||
# Also log the timing
|
||||
log_func = getattr(logging, log_level.lower(), logging.debug)
|
||||
full_name = f"{context}.{func_name}" if context else func_name
|
||||
log_func(f"⏱️ {full_name}: {duration:.2f}s")
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def print_timing_summary(min_time: float = 0.0):
|
||||
"""Print timing summary (convenience function)."""
|
||||
_global_tracker.print_summary(min_time)
|
||||
|
||||
|
||||
def print_hierarchical_timing_summary():
|
||||
"""Print hierarchical timing summary (convenience function)."""
|
||||
_global_tracker.print_hierarchical_summary()
|
||||
|
||||
|
||||
def reset_timing_stats():
|
||||
"""Reset timing statistics (convenience function)."""
|
||||
_global_tracker.reset()
|
||||
@@ -0,0 +1,29 @@
|
||||
from src.investment.exhibit_funcs import Exhibit, get_exhibit_list
|
||||
from src.prompts.fieldset import FieldSet
|
||||
|
||||
|
||||
def test_add_reimbursement_rows_sets_flag():
|
||||
exhibit = Exhibit(exhibit_page="1", exhibit_page_nums=["1"], exhibit_header="H", exhibit_text="text")
|
||||
exhibit.add_reimbursement_rows([{"a": 1}], [])
|
||||
assert exhibit.has_reimbursements is True
|
||||
assert len(exhibit.reimbursement_rows) == 1
|
||||
|
||||
|
||||
def test_set_exhibit_level_and_previous_dynamic_fields():
|
||||
prev = Exhibit(exhibit_page="1", exhibit_page_nums=["1"], exhibit_header="H1", exhibit_text="text1")
|
||||
dyn_fields = FieldSet()
|
||||
prev.dynamic_primary_fields = dyn_fields
|
||||
current = Exhibit(exhibit_page="2", exhibit_page_nums=["2"], exhibit_header="H2", exhibit_text="text2", prev_exhibit=prev)
|
||||
|
||||
current.set_exhibit_level_data({"X": "Y"}, FieldSet())
|
||||
assert current.exhibit_level_answers["X"] == "Y"
|
||||
assert current.get_previous_exhibit_dynamic_fields() is dyn_fields
|
||||
|
||||
|
||||
def test_get_exhibit_list_links_previous():
|
||||
text_dict = {"1": "A", "2": "B"}
|
||||
mapping = {"1": ["1"], "2": ["2"]}
|
||||
headers = {"1": "H1", "2": "H2"}
|
||||
exhibits = get_exhibit_list(text_dict, mapping, headers)
|
||||
assert len(exhibits) == 2
|
||||
assert exhibits[1].prev_exhibit is exhibits[0]
|
||||
@@ -0,0 +1,35 @@
|
||||
import time
|
||||
|
||||
from src.utils import timing_utils
|
||||
|
||||
|
||||
def test_timing_stats_add_and_avg():
|
||||
stats = timing_utils.TimingStats(name="unit")
|
||||
stats.add(0.1)
|
||||
stats.add(0.3)
|
||||
assert stats.count == 2
|
||||
assert stats.min_time == 0.1
|
||||
assert stats.max_time == 0.3
|
||||
assert abs(stats.avg_time - 0.2) < 1e-6
|
||||
|
||||
|
||||
def test_timing_tracker_record_and_reset():
|
||||
tracker = timing_utils.TimingTracker()
|
||||
tracker.record("step", 0.05, context="fileA")
|
||||
tracker.record("step", 0.10, context="fileA")
|
||||
stats = tracker.get_stats()
|
||||
assert "fileA.step" in stats
|
||||
assert stats["fileA.step"].count == 2
|
||||
tracker.reset()
|
||||
assert tracker.get_stats() == {}
|
||||
|
||||
|
||||
def test_timed_block_records_duration():
|
||||
tracker = timing_utils.get_tracker()
|
||||
tracker.reset()
|
||||
with timing_utils.timed_block("sleep_test", context="unit"):
|
||||
time.sleep(0.01)
|
||||
stats = tracker.get_stats()
|
||||
key = "unit.sleep_test"
|
||||
assert key in stats
|
||||
assert stats[key].count == 1
|
||||
Reference in New Issue
Block a user