diff --git a/src/config.py b/src/config.py index 7dcf202..367ee8a 100644 --- a/src/config.py +++ b/src/config.py @@ -227,11 +227,18 @@ PROV_TYPE = None OUTPUT_BASE = "outputs" -# Determine client-specific output directory -# saas → outputs/saas, clover → outputs/clients/clover, etc. +# Determine pipeline-type-aware output directory: +# saas → outputs/saas +# clients → outputs/clients/ (e.g. clover) +# vendors → outputs/vendors/ (e.g. la_care) _client_name = CLIENT[0].lower() if CLIENT and CLIENT[0] != "None" else "saas" +_vendors_path = os.path.join( + os.path.dirname(__file__), "pipelines", "vendors", _client_name +) if _client_name == "saas": CONSOLIDATED_OUTPUT_DIRECTORY = f"{OUTPUT_BASE}/saas" +elif os.path.isdir(_vendors_path): + CONSOLIDATED_OUTPUT_DIRECTORY = f"{OUTPUT_BASE}/vendors/{_client_name}" else: CONSOLIDATED_OUTPUT_DIRECTORY = f"{OUTPUT_BASE}/clients/{_client_name}" @@ -305,7 +312,7 @@ MODEL_ID_CLAUDE45_HAIKU = "us.anthropic.claude-haiku-4-5-20251001-v1:0" # Model aliases for easy upgrades MODEL_ALIASES = { - "sonnet_latest": MODEL_ID_CLAUDE4_SONNET, + "sonnet_latest": MODEL_ID_CLAUDE45_SONNET, "haiku_latest": MODEL_ID_CLAUDE45_HAIKU, "legacy_sonnet": MODEL_ID_CLAUDE35_SONNET, } diff --git a/src/constants/constants.py b/src/constants/constants.py index c37a539..26f6cf3 100644 --- a/src/constants/constants.py +++ b/src/constants/constants.py @@ -235,6 +235,18 @@ class Constants: .mapping ) + #################################### Vendor-Specific Lists #################################### + self.CUSTOMER_CONTRACT_TYPE = ( + ListBuilder() + .from_json(_path("constants/lists/care_source_contract_types.json")) + .list + ) + self.COMMODITY_CODES = ( + ListBuilder() + .from_json(_path("constants/lists/care_source_commodity_codes.json")) + .list + ) + #################################### Other #################################### self.COMPOUND_INDICATORS = ( ListBuilder() diff --git a/src/constants/lists/care_source_commodity_codes.json b/src/constants/lists/care_source_commodity_codes.json new file mode 100644 index 0000000..95b465e --- /dev/null +++ b/src/constants/lists/care_source_commodity_codes.json @@ -0,0 +1,56 @@ +[ + "Academic Provider", + "Advertising", + "Broker Sales", + "Business Development Svcs & Consulting", + "Call Center", + "Care Management", + "Claims Services", + "Clinical & Medical Consulting", + "Clinical Software", + "Community Partnership", + "Complex Health Services & Consulting", + "Compliance Services & Consulting", + "Compliance Software", + "Corporate Communications", + "Credentialing", + "Employee Benefits", + "Enrollment", + "Enterprise Delegated Credentialing", + "Facilities Equipment & Services", + "Finance Services & Consulting", + "Finance Software", + "Fraud Services & Consulting", + "Government Affairs", + "Hardware", + "Health Information Exchange", + "Healthcare Quality", + "HR Services & Consulting", + "HR Software", + "Independent Review Services", + "IT Consulting", + "IT Reseller", + "IT Software", + "IT Support Services", + "Language Services", + "Lease and Rent", + "Legal Services & Consulting", + "Marketing Services & Consulting", + "Member & Provider Surveys", + "Member Benefits", + "Member Education & Engagement", + "Office Supplies", + "Operations Consulting", + "Operations Software", + "Payment Integrity", + "Pharmacy Services & Consulting", + "Print and Fulfillment", + "Provider Network Services", + "Recruiting", + "Risk Adjustment", + "Staff Augmentation", + "Telecommunications", + "Training", + "Transportation", + "Utilization Management" +] diff --git a/src/constants/lists/care_source_contract_types.json b/src/constants/lists/care_source_contract_types.json new file mode 100644 index 0000000..dc0aa0e --- /dev/null +++ b/src/constants/lists/care_source_contract_types.json @@ -0,0 +1,32 @@ +[ + "Affiliation Agreement", + "Amendment", + "Amendment - TRICARE", + "Business Associate Agreement (BAA)", + "Community Partnership Agreement", + "Consultant Agreement", + "Data Share", + "Data Use", + "Delegated Services Agreement (DSA)", + "Direct Placement Agreement", + "Employment Agreement", + "Evaluation License Agreement (POC)", + "Field Marketing Sales Organization (FMSO)", + "Letter of Intent (LOI)", + "Master Services Agreement (MSA)", + "Master Services Agreement (MSA) - CSMV", + "Master Services Agreement (MSA) - MINI", + "Medical Consultant", + "Memorandum of Understanding (MOU)", + "Non-Disclosure Agreement (NDA)", + "Provider Delegated Agreement", + "Real Estate Lease", + "Renewal", + "Risk Acceptance Memo (RAM)", + "Sales Agent", + "Specialty Pharmacy", + "Staff Aug MSA", + "Statement of Work (SOW)", + "Statement of Work (SOW) - TRICARE", + "Trading Partner" +] diff --git a/src/core/registry.py b/src/core/registry.py index 6c8a0cd..a498898 100644 --- a/src/core/registry.py +++ b/src/core/registry.py @@ -1,15 +1,16 @@ """ -Client Pipeline Registry +Client and Vendor Pipeline Registry -Auto-discovers and manages client pipeline implementations. -Provides a unified interface for getting the correct pipeline based on client name. +Auto-discovers and manages client and vendor pipeline implementations. +Provides a unified interface for getting the correct pipeline based on name. Usage: from src.core.registry import PipelineRegistry registry = PipelineRegistry() - pipeline = registry.get_pipeline("molina") # Returns Molina pipeline - pipeline = registry.get_pipeline("saas") # Returns default SaaS pipeline + pipeline = registry.get_pipeline("molina") # Returns Molina client pipeline + pipeline = registry.get_pipeline("la_care") # Returns L.A. Care vendor pipeline + pipeline = registry.get_pipeline("saas") # Returns default SaaS pipeline """ import importlib @@ -20,10 +21,14 @@ from typing import Dict, Optional, Type, Any class PipelineRegistry: """ - Registry for client pipeline implementations. + Registry for client and vendor pipeline implementations. - Automatically discovers client implementations in pipelines/clients/ - and provides a unified interface for accessing them. + Automatically discovers implementations in: + - pipelines/clients/ (payer/health plan clients) + - pipelines/vendors/ (SG&A vendors such as L.A. Care) + + Vendor entries are stored with the same dict shape as clients but carry + an extra "pipeline_type": "vendor" key so callers can differentiate. """ _instance = None @@ -36,13 +41,14 @@ class PipelineRegistry: return cls._instance def __init__(self): - """Initialize the registry with discovered clients.""" + """Initialize the registry with discovered clients and vendors.""" if PipelineRegistry._initialized: return self._clients: Dict[str, dict] = {} self._base_path = Path(__file__).parent.parent / "pipelines" self._discover_clients() + self._discover_vendors() PipelineRegistry._initialized = True def _discover_clients(self) -> None: @@ -64,6 +70,7 @@ class PipelineRegistry: client_name = client_dir.name.lower() self._clients[client_name] = { "name": client_name, + "pipeline_type": "client", "path": client_dir, "has_config": (client_dir / "config.yaml").exists(), "has_preprocessing": (client_dir / "preprocessing").is_dir(), @@ -72,50 +79,104 @@ class PipelineRegistry: "has_prompts": (client_dir / "prompts").is_dir(), } + def _discover_vendors(self) -> None: + """ + Discover all vendor implementations in pipelines/vendors/. + + Each vendor directory should have: + - __init__.py + - file_processing.py (required — vendors own their full processing loop) + - prompts/ (optional prompt overrides) + - config.yaml (optional) + """ + vendors_path = self._base_path / "vendors" + + if not vendors_path.exists(): + return + + for vendor_dir in vendors_path.iterdir(): + if ( + vendor_dir.is_dir() + and not vendor_dir.name.startswith("_") + and ( + (vendor_dir / "file_processing.py").exists() + or (vendor_dir / "config.yaml").exists() + ) + ): + vendor_name = vendor_dir.name.lower() + self._clients[vendor_name] = { + "name": vendor_name, + "pipeline_type": "vendor", + "path": vendor_dir, + "has_config": (vendor_dir / "config.yaml").exists(), + "has_preprocessing": (vendor_dir / "preprocessing").is_dir(), + "has_extraction": (vendor_dir / "extraction").is_dir(), + "has_postprocessing": (vendor_dir / "postprocessing").is_dir(), + "has_prompts": (vendor_dir / "prompts").is_dir(), + } + def list_clients(self) -> list: - """Return list of all registered client names.""" + """Return list of all registered client and vendor names.""" return list(self._clients.keys()) + def list_vendors(self) -> list: + """Return list of registered vendor names only.""" + return [ + name + for name, info in self._clients.items() + if info.get("pipeline_type") == "vendor" + ] + + def is_vendor(self, name: str) -> bool: + """Return True if the registered name is a vendor pipeline.""" + info = self._clients.get(name.lower()) + return info is not None and info.get("pipeline_type") == "vendor" + def get_client_info(self, client_name: str) -> Optional[dict]: """ - Get information about a specific client. + Get information about a specific client or vendor. Args: - client_name: Name of the client (case-insensitive) + client_name: Name of the client/vendor (case-insensitive) Returns: - Dict with client info or None if not found + Dict with info or None if not found """ return self._clients.get(client_name.lower()) def has_client(self, client_name: str) -> bool: - """Check if a client is registered.""" + """Check if a client or vendor is registered.""" return client_name.lower() in self._clients def get_module(self, client_name: str, module_type: str) -> Any: """ - Get a specific module for a client, falling back to saas if not overridden. + Get a specific module for a client or vendor, falling back to saas if not overridden. + + For vendor pipelines, the module path is built from pipelines/vendors// + instead of pipelines/clients//. Args: - client_name: Name of the client (or "saas" for default) + client_name: Name of the client/vendor (or "saas" for default) module_type: One of "preprocessing", "extraction", "postprocessing", "prompts" Returns: The imported module Example: - preprocessing = registry.get_module("molina", "preprocessing") - # Returns molina's preprocessing if it exists, else saas preprocessing + fp = registry.get_module("la_care", "file_processing") """ client_name = client_name.lower() - # Check if client has this module overridden if client_name != "saas" and self.has_client(client_name): - client_info = self._clients[client_name] - has_override = client_info.get(f"has_{module_type}", False) + info = self._clients[client_name] + pipeline_type = info.get("pipeline_type", "client") + has_override = info.get(f"has_{module_type}", False) if has_override: - module_path = f"src.pipelines.clients.{client_name}.{module_type}" + if pipeline_type == "vendor": + module_path = f"src.pipelines.vendors.{client_name}.{module_type}" + else: + module_path = f"src.pipelines.clients.{client_name}.{module_type}" try: return importlib.import_module(module_path) except ImportError: @@ -127,10 +188,10 @@ class PipelineRegistry: def get_config(self, client_name: str) -> Optional[dict]: """ - Load client configuration from config.yaml. + Load client/vendor configuration from config.yaml. Args: - client_name: Name of the client + client_name: Name of the client/vendor Returns: Dict with config or None if no config exists @@ -153,9 +214,10 @@ class PipelineRegistry: return None def refresh(self) -> None: - """Re-discover clients (useful after adding new client directories).""" + """Re-discover clients and vendors (useful after adding new directories).""" self._clients.clear() self._discover_clients() + self._discover_vendors() # Module-level singleton instance diff --git a/src/pipelines/runner.py b/src/pipelines/runner.py index 6f61eae..c9ee394 100644 --- a/src/pipelines/runner.py +++ b/src/pipelines/runner.py @@ -41,22 +41,31 @@ from src.qc_qa.pipeline import ( def get_file_processing_module(client: str): - """Get the file_processing module for a client. + """Get the file_processing module (or processor instance) for a client or vendor. + + Vendors are always routed through the generic VendorProcessor, which is + driven by vendor_fields.json + per-vendor config.yaml. No per-vendor + file_processing.py is required or consulted for vendor pipelines. Args: - client: Client name ("saas", "clover", etc.) + client: Client/vendor name ("saas", "clover", "la_care", etc.) Returns: - The file_processing module for the specified client + An object with a process_file(file_object, constants, run_timestamp) method. """ if client == "saas": from src.pipelines.saas import file_processing return file_processing - else: - # Dynamic import for client - module_path = f"src.pipelines.clients.{client}.file_processing" - return importlib.import_module(module_path) + + registry = get_registry() + if registry.is_vendor(client): + from src.pipelines.vendors.shared.generic_processor import VendorProcessor + + return VendorProcessor(client) + + module_path = f"src.pipelines.clients.{client}.file_processing" + return importlib.import_module(module_path) def safe_process_file(item, constants, run_timestamp, file_processing): @@ -106,10 +115,10 @@ def safe_process_file(item, constants, run_timestamp, file_processing): def main(client: str = "saas", testing=False, test_params={}): - """Main entry point for all clients. + """Main entry point for all clients and vendors. Args: - client: Client name ("saas", "clover", etc.) + client: Client or vendor name ("saas", "clover", "la_care", etc.) testing: Whether running in test mode test_params: Test parameters if testing=True """ @@ -125,11 +134,11 @@ def main(client: str = "saas", testing=False, test_params={}): f"Invalid configuration: MAX_WORKERS must be non-negative (got {config.MAX_WORKERS})" ) - # Validate client + # Validate client/vendor registry = get_registry() if client != "saas" and not registry.has_client(client): raise ValueError( - f"Unknown client: {client}. Available clients: {registry.list_clients()}" + f"Unknown client/vendor: {client}. " f"Available: {registry.list_clients()}" ) # Get client-specific file processing module diff --git a/src/pipelines/vendors/__init__.py b/src/pipelines/vendors/__init__.py new file mode 100644 index 0000000..8556353 --- /dev/null +++ b/src/pipelines/vendors/__init__.py @@ -0,0 +1 @@ +"""Vendor pipeline implementations.""" diff --git a/src/pipelines/vendors/bcbs_arizona/__init__.py b/src/pipelines/vendors/bcbs_arizona/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pipelines/vendors/bcbs_arizona/config.yaml b/src/pipelines/vendors/bcbs_arizona/config.yaml new file mode 100644 index 0000000..8eed972 --- /dev/null +++ b/src/pipelines/vendors/bcbs_arizona/config.yaml @@ -0,0 +1,8 @@ +payer_name: "Blue Cross Blue Shield of Arizona" +usage_prefix: "BCBS_AZ" + +# Fields whose non-empty value generates a companion _IND = Y/N column +derived_indicators: + - PERFORMANCE_GUARANTEE_VALUE + +# No indicator_only_fields for bcbs_arizona diff --git a/src/pipelines/vendors/bcbs_arizona/prompts/__init__.py b/src/pipelines/vendors/bcbs_arizona/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pipelines/vendors/care_source/__init__.py b/src/pipelines/vendors/care_source/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pipelines/vendors/care_source/config.yaml b/src/pipelines/vendors/care_source/config.yaml new file mode 100644 index 0000000..f20469a --- /dev/null +++ b/src/pipelines/vendors/care_source/config.yaml @@ -0,0 +1,70 @@ +payer_name: "CareSource" +usage_prefix: "CARE_SOURCE" + +derived_indicators: [] + +static_fields: + Region: "[MA One Care][ MA Senior Care]" + ContractStatus: "Published" + MasterRecord: "CCA2025" + +derived_mappings: + - output_field: "Heirarchical Type" + source_field: "cus_ContractType" + mapping: + "Affiliation Agreement": "MasterAgreement" + "Amendment": "SubAgreement" + "Amendment - TRICARE": "SubAgreement" + "Business Associate Agreement (BAA)": "StandAlone" + "Community Partnership Agreement": "MasterAgreement" + "Consultant Agreement": "MasterAgreement" + "Data Share": "SubAgreement" + "Data Use": "SubAgreement" + "Delegated Services Agreement (DSA)": "MasterAgreement" + "Direct Placement Agreement": "MasterAgreement" + "Employment Agreement": "MasterAgreement" + "Evaluation License Agreement (POC)": "StandAlone" + "Field Marketing Sales Organization (FMSO)": "MasterAgreement" + "Letter of Intent (LOI)": "StandAlone" + "Master Services Agreement (MSA)": "MasterAgreement" + "Master Services Agreement (MSA) - CSMV": "MasterAgreement" + "Master Services Agreement (MSA) - MINI": "MasterAgreement" + "Medical Consultant": "MasterAgreement" + "Memorandum of Understanding (MOU)": "StandAlone" + "Non-Disclosure Agreement (NDA)": "StandAlone" + "Provider Delegated Agreement": "MasterAgreement" + "Real Estate Lease": "MasterAgreement" + "Renewal": "SubAgreement" + "Risk Acceptance Memo (RAM)": "StandAlone" + "Sales Agent": "MasterAgreement" + "Specialty Pharmacy": "MasterAgreement" + "Staff Aug MSA": "MasterAgreement" + "Statement of Work (SOW)": "SubAgreement" + "Statement of Work (SOW) - TRICARE": "SubAgreement" + "Trading Partner": "StandAlone" + +column_order: + - FILE_NAME + - NUM_PAGES + - TITLE + - VENDOR_NAME + - VENDOR_CONTACTS + - VENDOR_NOTICES_LOCATION + - EFFECTIVE_DATE + - TERMINATION_DATE + - TERM_TYPE + - AUTO_RENEWAL_PERIOD + - MaxAutoRenewalsAllowed + - TERMINATION_NOTICE_PERIOD + - TERM_CLAUSE + - Termination_Clause_Section_Num + - DESCRIPTION_OF_SERVICES + - AMOUNT + - Fixed_or_Annual_Spend_Amount + - Commodity + - cus_ContractType + - Heirarchical Type + - Region + - ContractStatus + - MasterRecord + - CLIENT_NAME diff --git a/src/pipelines/vendors/care_source/prompts/__init__.py b/src/pipelines/vendors/care_source/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pipelines/vendors/generic/config.yaml b/src/pipelines/vendors/generic/config.yaml new file mode 100644 index 0000000..5ac0a08 --- /dev/null +++ b/src/pipelines/vendors/generic/config.yaml @@ -0,0 +1,20 @@ +payer_name: "Unknown" +usage_prefix: "GENERIC" +derived_indicators: [] +column_order: + - FILE_NAME + - CLIENT_NAME + - TITLE + - AMENDMENT_OR_CHANGE_ORDER + - EFFECTIVE_DATE + - TERMINATION_DATE + - TERMINATION_NOTICE_PERIOD + - AUTO_RENEWAL_PERIOD + - TERM_TYPE + - VENDOR_NAME + - PAYER_NAME + - PAYMENT_TYPE + - AMOUNT + - DESCRIPTION_OF_SERVICES + - TERM_CLAUSE + - NUM_PAGES diff --git a/src/pipelines/vendors/la_care/__init__.py b/src/pipelines/vendors/la_care/__init__.py new file mode 100644 index 0000000..dec99fa --- /dev/null +++ b/src/pipelines/vendors/la_care/__init__.py @@ -0,0 +1 @@ +"""L.A. Care vendor pipeline implementation.""" diff --git a/src/pipelines/vendors/la_care/config.yaml b/src/pipelines/vendors/la_care/config.yaml new file mode 100644 index 0000000..c71fcec --- /dev/null +++ b/src/pipelines/vendors/la_care/config.yaml @@ -0,0 +1,16 @@ +payer_name: "L.A. Care" +usage_prefix: "LA_CARE" + +# Fields whose non-empty value generates a companion _IND = Y/N column +derived_indicators: + - ASSIGNMNET_CLAUSE + - CONFIDENTIAL_DATA_LANGUAGE + - INDEMNIFICATION_CLAUSE + - MEDICARE_PROVISIONS + - PERFORMANCE_GUARANTEE_VALUE + - VENDOR_ADMIN_CLINICAL_FUNCTIONS + - WARRANTY_CLAUSE + +# Fields extracted only to derive their _IND column; excluded from output columns +indicator_only_fields: + - MEDICARE_PROVISIONS diff --git a/src/pipelines/vendors/la_care/prompts/__init__.py b/src/pipelines/vendors/la_care/prompts/__init__.py new file mode 100644 index 0000000..1fc41ef --- /dev/null +++ b/src/pipelines/vendors/la_care/prompts/__init__.py @@ -0,0 +1 @@ +"""L.A. Care vendor prompt functions.""" diff --git a/src/pipelines/vendors/la_care/prompts/prompt_calls.py b/src/pipelines/vendors/la_care/prompts/prompt_calls.py new file mode 100644 index 0000000..80846f1 --- /dev/null +++ b/src/pipelines/vendors/la_care/prompts/prompt_calls.py @@ -0,0 +1,234 @@ +""" +L.A. Care Vendor Prompt Calls + +All LLM invocations for the L.A. Care vendor pipeline. + +Field/FieldSet pattern: + - one_to_one fields → ONE_TO_ONE_SINGLE_FIELD_TEMPLATE / ONE_TO_ONE_MULTI_FIELD_TEMPLATE + - one_to_n fields → BOTTOM_UP_PRIMARY / BOTTOM_UP_SLA_KPI (page-level extraction) + +_INSTRUCTION functions are cached separately via cache_warming in the runner. +""" + +import json +import logging +from collections import defaultdict + +import src.pipelines.vendors.prompt_templates as prompt_templates +import src.utils.llm_utils as llm_utils +import src.utils.string_utils as string_utils +from src.prompts.fieldset import Field, FieldSet + + +# --------------------------------------------------------------------------- +# Cache-warming helpers +# --------------------------------------------------------------------------- + + +def get_cacheable_instructions() -> dict: + """Return all cacheable instructions for L.A. Care vendor pipeline. + + Consumed by the runner during cache warming before parallel processing. + """ + cacheable = {} + try: + cacheable["ONE_TO_ONE_SINGLE_FIELD"] = ( + prompt_templates.ONE_TO_ONE_SINGLE_FIELD_INSTRUCTION() + ) + cacheable["ONE_TO_ONE_MULTI_FIELD"] = ( + prompt_templates.ONE_TO_ONE_MULTI_FIELD_INSTRUCTION() + ) + cacheable["BOTTOM_UP_PRIMARY"] = ( + prompt_templates.BOTTOM_UP_PRIMARY_INSTRUCTION() + ) + cacheable["BOTTOM_UP_SLA_KPI"] = ( + prompt_templates.BOTTOM_UP_SLA_KPI_INSTRUCTION() + ) + except Exception: + pass + return cacheable + + +# --------------------------------------------------------------------------- +# one_to_one: smart-chunked fields (fields with keywords) +# --------------------------------------------------------------------------- + + +def prompt_smart_chunked_fields( + context: str, + fields: FieldSet, + filename: str, +) -> dict: + """Run LLM extraction for a group of keyword-matched fields. + + Single-field groups use ONE_TO_ONE_SINGLE_FIELD_TEMPLATE; + multi-field groups use ONE_TO_ONE_MULTI_FIELD_TEMPLATE. + + Args: + context: The keyword-matched text chunk for this field group. + fields: FieldSet containing the fields to extract for this chunk. + filename: File being processed (for logging/tracking). + + Returns: + dict of {field_name: extracted_value} + """ + if not fields.contains_fields(): + return {} + + prompt_dict = fields.get_prompt_dict() + + if len(fields.fields) == 1: + field = fields.fields[0] + prompt = prompt_templates.ONE_TO_ONE_SINGLE_FIELD_TEMPLATE( + context, {field.field_name: field.get_prompt()} + ) + raw = llm_utils.invoke_claude( + prompt, + "sonnet_latest", + filename, + max_tokens=8192, + cache=True, + instruction=prompt_templates.ONE_TO_ONE_SINGLE_FIELD_INSTRUCTION(), + usage_label="LA_CARE_SINGLE_FIELD", + ) + else: + prompt = prompt_templates.ONE_TO_ONE_MULTI_FIELD_TEMPLATE(context, prompt_dict) + raw = llm_utils.invoke_claude( + prompt, + "sonnet_latest", + filename, + max_tokens=8192, + cache=True, + instruction=prompt_templates.ONE_TO_ONE_MULTI_FIELD_INSTRUCTION(), + usage_label="LA_CARE_MULTI_FIELD", + ) + + result = string_utils.universal_json_load(raw) + if not isinstance(result, dict): + logging.warning( + f"[{filename}] Unexpected LLM response format for fields " + f"{fields.list_fields()}: {raw[:200]}" + ) + return {} + return result + + +# --------------------------------------------------------------------------- +# one_to_one: full-context fields (no keywords — run against entire document) +# --------------------------------------------------------------------------- + +_FULL_CONTEXT_BATCH_SIZE = 10 + + +def prompt_full_context_fields( + contract_text: str, + fields: FieldSet, + filename: str, +) -> dict: + """Run LLM extraction for all full_context one_to_one fields. + + Fields are batched into groups of _FULL_CONTEXT_BATCH_SIZE to avoid + truncation when the LLM receives too many questions in a single call. + + Args: + contract_text: Full contract text. + fields: FieldSet of full_context fields. + filename: File being processed. + + Returns: + dict of {field_name: extracted_value} + """ + if not fields.contains_fields(): + return {} + + all_fields = fields.fields + results = {} + + for i in range(0, len(all_fields), _FULL_CONTEXT_BATCH_SIZE): + batch = all_fields[i : i + _FULL_CONTEXT_BATCH_SIZE] + batch_fieldset = FieldSet(fields=batch) + prompt_dict = batch_fieldset.get_prompt_dict() + prompt = prompt_templates.ONE_TO_ONE_MULTI_FIELD_TEMPLATE( + contract_text, prompt_dict + ) + raw = llm_utils.invoke_claude( + prompt, + "sonnet_latest", + filename, + max_tokens=8192, + cache=True, + instruction=prompt_templates.ONE_TO_ONE_MULTI_FIELD_INSTRUCTION(), + usage_label="LA_CARE_FULL_CONTEXT", + ) + batch_result = string_utils.universal_json_load(raw) + if not isinstance(batch_result, dict): + logging.warning( + f"[{filename}] Unexpected LLM response format for full_context batch " + f"{i // _FULL_CONTEXT_BATCH_SIZE + 1} " + f"(fields {[f.field_name for f in batch]}): {raw[:200]}" + ) + else: + results.update(batch_result) + + return results + + +# --------------------------------------------------------------------------- +# one_to_n: page-level bottom-up extraction +# --------------------------------------------------------------------------- + + +def prompt_bottom_up_page( + page_text: str, + page_num: str, + template_fn, + instruction_fn, + payer: str, + filename: str, + usage_label: str, +) -> list: + """Run bottom-up extraction on a single page. + + Args: + page_text: Text content of the page. + page_num: Page identifier (for logging). + template_fn: Template function (e.g. prompt_templates.BOTTOM_UP_PRIMARY). + instruction_fn: Corresponding _INSTRUCTION function for caching. + payer: Payer name to inject into the prompt (e.g. "L.A. Care"). + filename: File being processed. + usage_label: Label for usage tracking. + + Returns: + list of dicts extracted from the page, or empty list on failure. + """ + prompt = template_fn(page_text, payer) + raw = llm_utils.invoke_claude( + prompt, + "sonnet_latest", + filename, + max_tokens=8192, + cache=True, + instruction=instruction_fn(), + usage_label=usage_label, + ) + try: + result = json.loads(raw) + if isinstance(result, list): + for item in result: + item["PAGE_NUM"] = page_num + return result + except (json.JSONDecodeError, ValueError): + # Try wrapping in list brackets + try: + result = json.loads(f"[{raw}]") + if isinstance(result, list): + for item in result: + item["PAGE_NUM"] = page_num + return result + except Exception: + pass + logging.warning( + f"[{filename}] Page {page_num}: Failed to parse bottom-up response: " + f"{raw[:200]}" + ) + return [] diff --git a/src/pipelines/vendors/prompt_templates.py b/src/pipelines/vendors/prompt_templates.py new file mode 100644 index 0000000..97078bb --- /dev/null +++ b/src/pipelines/vendors/prompt_templates.py @@ -0,0 +1,212 @@ +""" +Vendor Prompt Templates + +Prompt template functions used exclusively by the vendor extraction pipeline. +Kept separate from src/prompts/prompt_templates.py so vendor-side changes +never affect non-vendor (SaaS/client) pipelines. + +Functions here mirror their counterparts in the main prompt_templates module +but are owned by the vendor pipeline going forward. +""" + +# --------------------------------------------------------------------------- +# one_to_one extraction templates +# --------------------------------------------------------------------------- + + +def ONE_TO_ONE_SINGLE_FIELD_TEMPLATE(context: str, field_prompt: dict[str, str]): + return f"""The following is a contract (or excerpt thereof). +Answer the following question: {field_prompt} + +## START CONTRACT TEXT ## +{context} +## END CONTRACT TEXT ## + +Briefly explain your answer, then put the final answer in a properly-formatted JSON dictionary, where the key is the field name and value is the answer. If there is no valid answer, return "N/A". +""" + + +def ONE_TO_ONE_SINGLE_FIELD_INSTRUCTION() -> str: + return ( + "[OBJECTIVE]\n" + "Answer a single, deterministic question about the contract text. Return result in pipes as instructed." + ) + + +def ONE_TO_ONE_MULTI_FIELD_TEMPLATE(context, questions: dict[str, str]): + return f"""The following is a contract (or excerpt thereof). Answer the following questions: {questions} + +## START CONTRACT TEXT ## +{context.replace('"', "'")} +## END CONTRACT TEXT ## + +For each question, explain your reasoning. Then provide your final answers in a JSON dictionary format. Explanation MUST NOT contain any curly brackets '{{' or '}}'. Replace them with '()' instead. + +The JSON should: +- Include every key +- Use 'N/A' for any question where the requested information doesn't apply to this context +- Use 'UNKNOWN' for any question where the requested information should exist but cannot be clearly identified +- Contain only the final answers, not explanations +- NOT contain any curly brackets '{{' or '}}' inside. +- If curly bracket still exists inside JSON, replace them with '()' instead. + +Example format: +I found the effective date in section 2.1 which states... +The term length appears in two places but I'm selecting... + +{{ + "effective_date": "January 1, 2024", + "term_length": "36 months" + }} + """ + + +def ONE_TO_ONE_MULTI_FIELD_INSTRUCTION() -> str: + return ( + "[OBJECTIVE]\n" + "Answer multiple deterministic questions about contract text. Follow JSON-only output rules in the prompt." + ) + + +# --------------------------------------------------------------------------- +# one_to_n bottom-up extraction templates +# --------------------------------------------------------------------------- + + +def BOTTOM_UP_PRIMARY_INSTRUCTION() -> str: + return ( + "[OBJECTIVE]\n" + "Extract all reimbursement line items (products/services being procured) from a single page of an SG&A vendor contract. " + "Return a JSON array where each object represents one distinct product or service with its associated pricing. " + "Return an empty list [] if the page has no reimbursement information." + ) + + +def BOTTOM_UP_PRIMARY(page: str, payer: str) -> str: + """ + Returns prompt for page-level vendor service/reimbursement extraction. + Call BOTTOM_UP_PRIMARY_INSTRUCTION() separately for the cached instruction. + """ + return f"""### PAGE START ### {page} ### PAGE END + +The preceding text is one page of a contract between a Payer {payer} and a vendor providing products or services related to Selling, General, and Administrative Expenses (SG&A). Your job is to extract attributes related to the reimbursement of different products and services. Ensure every compensation term (with a % or $ value) is captured. + +Some values are found in sections explicitly identified as examples - these must be omitted from output. Other values might be related to liability - these must also be excluded. + +You should return at least one json object for each listed product or service if there is a quantifiable rate of reimbursement for it. This could come in the form of a per unit price, percent of a larger sum, or other reimbursement methods. +There is often, but not always, a finite volume of units listed alongside the product or service agreed to be paid for. +Some items may be listed as complementary or with a fee of $0, but you should still return these if they're being procured. Simply list the items as you would any other reimbursement, but with a unit cost of $0. +Make sure to only return items that are actually being procured! There may be an extensive list of options that the vendor offers in the agreement or purchase order, but do not assume they are necessarily being purchased unless there's an associated price and/or quantity of units present. +You also shouldn't return subtotals or total purchase amounts when they're included in a table of procured items, because there should be no double counting of purchases. Operate under the assumption that you should return the most granular procurement information possible, and that one entry returned equals one type of product or service. +This means that if a certain service or subscription is broken up into different stages, phases, or tiers that come at different price points, return each one separately, assuming they're all being purchased. +It is possible that a page will not have any attributes. When this is the case, return an empty list. +You SHOULD NOT RETURN ANY SERVICE LEVEL AGREEMENTS as reimbursement items, they may pertain to reimbursement but will not involve actually procuring a service. Remove any service level agreements or performance guarantees from any list of objects you return. + +Here are the attributes to be included in each dictionary: +'DESCRIPTION_OF_SERVICES': Write the description of the products or services agreed upon between the payer and vendor. +'LICENSE_TYPE': If the products being procured are licenses to software or other services, what type of license are being granted? Return 'perpetual', 'subscription', or 'N/A'. +'UNITS': What are the units listed for the products or services? +'COUNT_OF_UNITS': How many units are being ordered? Only return an integer (or decimal, if necessary) value. If there are no units present, list 'N/A'. +'UNIT_COST': What is the cost per unit? DO NOT TRY TO DERIVE THIS IF IT IS NOT PRESENT, just return 'N/A'. +'MINIMUM_VOLUME_COMMITMENT': If there is an obligation to commit to a minimum amount of total spend, minimum volume of units, or minimum fees paid, write it here. Otherwise, write 'N/A'. +'AMENDMENT_TYPE': If the agreement is amending a previous schedule, is this specific service being added, removed, or replaced? Respond with: 'Add', 'Remove', or 'Replace'. +'PURCHASE_ORDER_IND': Does the current entry refer to a purchase order sheet? Return 'Y' if yes, otherwise return 'N'. + +Only return the list, with no other commentary or explanation. Ensure you abide by proper JSON formatting. +""" + + +def BOTTOM_UP_SLA_KPI_INSTRUCTION() -> str: + return ( + "[OBJECTIVE]\n" + "Extract all Service Level Agreement (SLA) and Key Performance Indicator (KPI) entries from a single page of an SG&A vendor contract.\n\n" + "[OUTPUT FORMAT — STRICTLY ENFORCED]\n" + "- Respond with a raw JSON array only. No explanation, no commentary, no preamble.\n" + "- Each element is a JSON object representing one distinct SLA or KPI metric.\n" + "- If the page contains no SLA or KPI information, respond with exactly: []\n" + "- Do NOT wrap output in markdown code fences (no ```json or ```).\n" + "- Do NOT repeat or echo any part of the input or instructions.\n" + "- Your entire response must be valid JSON parseable by json.loads().\n" + "- If you must include any reasoning, place it BEFORE the JSON array, never after." + ) + + +def BOTTOM_UP_SLA_KPI(page: str, payer: str) -> str: + """ + Returns prompt for page-level SLA/KPI extraction. + Call BOTTOM_UP_SLA_KPI_INSTRUCTION() separately for the cached instruction. + """ + return f"""### PAGE START ### +{page} +### PAGE END ### + +The above is one page from a contract between Payer "{payer}" and an SG&A vendor. Extract every SLA and KPI entry present. + +Each JSON object must have exactly these keys (use "N/A" if information is absent): +- METRIC_NAME: Short label or definition for the metric. +- PURPOSE: Why the metric exists. +- MEASUREMENT: How the metric is calculated or measured. +- ASSUMPTIONS: Any assumptions used in calculation. +- STANDARDS: The expected performance threshold or target. +- REPORTING_FREQUENCY: How often the metric is measured/reported. +- AMENDMENT_TYPE: If amending a prior metric list — "Add", "Remove", or "Replace". Otherwise "N/A". + +Rules: +- Include all SLA/KPI entries on the page, including those in tables. +- Capture distinguishing details (severity level, region, time zone, etc.) in METRIC_NAME or MEASUREMENT. +- If one metric can be calculated in multiple ways, create a separate object for each variant. +- If there are no SLAs or KPIs on this page, return []. + +Respond with the JSON array only. Start your response with [ and end with ]. +""" + + +# --------------------------------------------------------------------------- +# joint extraction templates (related fields extracted together for consistency) +# --------------------------------------------------------------------------- + + +def JOINT_FIELD_TEMPLATE( + context: str, questions: dict[str, str], consistency_rule: str = "" +) -> str: + rule_section = ( + f"\n\nCONSISTENCY RULES — apply these before finalizing answers:\n{consistency_rule}\n" + if consistency_rule + else "" + ) + return f"""The following is a contract (or excerpt thereof). +Answer the following questions: {questions}{rule_section} + +## START CONTRACT TEXT ## +{context.replace('"', "'")} +## END CONTRACT TEXT ## + +Briefly explain your reasoning for each field, then output a JSON dictionary with all field values. +Explanation MUST NOT contain any curly brackets '{{' or '}}'. Replace them with '()' instead. + +The JSON must: +- Include every key from the questions above +- Use 'N/A' if the information does not apply +- Use 'NOT_FOUND' if the information should exist but cannot be identified +- Contain only the final answers, no explanations +- Use no curly brackets inside string values + +You MUST always end your response with the JSON dictionary, even if you cannot find the values. + +Example: +Section 3.1 states a monthly rate of $5,000, so PAYMENT_TYPE = Monthly and AMOUNT = the monthly rate. + +{{ + "PAYMENT_TYPE": "Monthly", + "AMOUNT": "5000" +}} +""" + + +def JOINT_FIELD_INSTRUCTION() -> str: + return ( + "[OBJECTIVE]\n" + "Extract multiple related fields from a contract in a single pass, ensuring the values are " + "logically consistent with each other. Apply all consistency rules in the prompt before " + "finalizing your answers. Follow JSON-only output rules." + ) diff --git a/src/pipelines/vendors/sg_a/__init__.py b/src/pipelines/vendors/sg_a/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pipelines/vendors/sg_a/config.yaml b/src/pipelines/vendors/sg_a/config.yaml new file mode 100644 index 0000000..837c720 --- /dev/null +++ b/src/pipelines/vendors/sg_a/config.yaml @@ -0,0 +1,8 @@ +payer_name: "Centene Corporation" +usage_prefix: "SGA" + +# Fields whose non-empty value generates a companion _IND = Y/N column +derived_indicators: + - PERFORMANCE_GUARANTEE_VALUE + +# No indicator_only_fields for sg_a diff --git a/src/pipelines/vendors/sg_a/prompts/__init__.py b/src/pipelines/vendors/sg_a/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pipelines/vendors/shared/__init__.py b/src/pipelines/vendors/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pipelines/vendors/shared/extraction.py b/src/pipelines/vendors/shared/extraction.py new file mode 100644 index 0000000..96e10f8 --- /dev/null +++ b/src/pipelines/vendors/shared/extraction.py @@ -0,0 +1,385 @@ +""" +Shared Vendor Extraction Engine + +Generic extraction functions used by all vendor file_processing modules. + +Field types supported: + - smart_chunking → keyword-match pages → chunk → LLM (one_to_one) + - full_context → full document, batched 10 fields/call (one_to_one) + - full_context_single → full document, batched call; fields with depends_on + receive their dependency value injected into the prompt + - bottom_up_page_level → per-page parallel extraction (one_to_n) +""" + +import concurrent.futures +import logging +from collections import defaultdict + +import src.pipelines.vendors.prompt_templates as prompt_templates +import src.utils.llm_utils as llm_utils +import src.utils.string_utils as string_utils +from src.prompts.fieldset import Field, FieldSet + +_FULL_CONTEXT_BATCH_SIZE = 10 +_FULL_CONTEXT_MAX_CHARS = 600_000 + + +# --------------------------------------------------------------------------- +# smart_chunking +# --------------------------------------------------------------------------- + + +def run_smart_chunked_fields( + smart_fields: FieldSet, + text_dict: dict, + filename: str, + usage_prefix: str = "VENDOR", +) -> dict: + """Keyword-match pages and run prompts per keyword group. + + Fields sharing identical keyword sets are batched into a single LLM call. + Single-field groups use ONE_TO_ONE_SINGLE_FIELD_TEMPLATE. + Multi-field groups use ONE_TO_ONE_MULTI_FIELD_TEMPLATE. + + Args: + smart_fields: FieldSet of smart_chunking fields. + text_dict: page_number → page_text mapping. + filename: File being processed (for logging/tracking). + usage_prefix: Prefix for usage_label (e.g. "LA_CARE"). + + Returns: + dict of {field_name: extracted_value} + """ + if not smart_fields.contains_fields(): + return {} + + # Group fields by their (frozen) keyword signature so fields with the same + # keyword set fire a single LLM call. + groups: dict[frozenset, list] = defaultdict(list) + for field in smart_fields.fields: + key = frozenset(field.keywords or []) + groups[key].append(field) + + answers = {} + for keyword_set, group_fields in groups.items(): + if not keyword_set: + continue + chunk = _extract_keyword_chunk(text_dict, keyword_set, group_fields[0]) + if not chunk: + continue + group_fieldset = FieldSet(fields=group_fields) + result = _prompt_chunked(chunk, group_fieldset, filename, usage_prefix) + answers.update(result) + + return answers + + +def _prompt_chunked( + context: str, + fields: FieldSet, + filename: str, + usage_prefix: str, +) -> dict: + """Call LLM for a keyword-matched chunk and a group of fields.""" + if not fields.contains_fields(): + return {} + + if len(fields.fields) == 1: + field = fields.fields[0] + prompt = prompt_templates.ONE_TO_ONE_SINGLE_FIELD_TEMPLATE( + context, {field.field_name: field.get_prompt()} + ) + raw = llm_utils.invoke_claude( + prompt, + "sonnet_latest", + filename, + max_tokens=8192, + cache=True, + instruction=prompt_templates.ONE_TO_ONE_SINGLE_FIELD_INSTRUCTION(), + usage_label=f"{usage_prefix}_SINGLE_FIELD", + ) + else: + prompt = prompt_templates.ONE_TO_ONE_MULTI_FIELD_TEMPLATE( + context, fields.get_prompt_dict() + ) + raw = llm_utils.invoke_claude( + prompt, + "sonnet_latest", + filename, + max_tokens=8192, + cache=True, + instruction=prompt_templates.ONE_TO_ONE_MULTI_FIELD_INSTRUCTION(), + usage_label=f"{usage_prefix}_MULTI_FIELD", + ) + + result = string_utils.universal_json_load(raw) + if not isinstance(result, dict): + logging.warning( + f"[{filename}] Unexpected LLM response format for fields " + f"{fields.list_fields()}: {raw[:200]}" + ) + return {} + return result + + +def _extract_keyword_chunk( + text_dict: dict, + keyword_set: frozenset, + reference_field: Field, +) -> str: + """Return concatenated text from pages that match any keyword in the set.""" + case_sensitive = getattr(reference_field, "case_sensitive", False) + matching_pages = [] + + for _, page_text in text_dict.items(): + compare_text = page_text if case_sensitive else page_text.lower() + for keyword in keyword_set: + compare_kw = keyword if case_sensitive else keyword.lower() + if compare_kw in compare_text: + matching_pages.append(page_text) + break + + if not matching_pages: + return "" + + chunk = "\n\n".join(matching_pages) + return chunk[:100_000] + + +# --------------------------------------------------------------------------- +# full_context / full_context_single (batched) +# --------------------------------------------------------------------------- + + +def run_full_context_fields( + contract_text: str, + fields: FieldSet, + filename: str, + usage_prefix: str = "VENDOR", + prompt_overrides: dict | None = None, + constants=None, +) -> dict: + """Run LLM extraction for full_context and full_context_single fields. + + Fields are batched into groups of _FULL_CONTEXT_BATCH_SIZE to avoid + truncation when the LLM receives too many questions in a single call. + + Args: + contract_text: Full contract text. + fields: FieldSet of fields to extract. + filename: File being processed. + usage_prefix: Prefix for usage_label. + prompt_overrides: Optional dict of {field_name: augmented_prompt}. When a + field_name is present here its override is used instead of field.get_prompt(). + Used to inject dependency context (e.g. PAYMENT_TYPE) into a field's prompt + without splitting the batch. + + Returns: + dict of {field_name: extracted_value} + """ + if not fields.contains_fields(): + return {} + + all_fields = fields.fields + results = {} + overrides = prompt_overrides or {} + + if len(contract_text) > _FULL_CONTEXT_MAX_CHARS: + logging.warning( + f"[{filename}] contract_text length {len(contract_text):,} exceeds " + f"{_FULL_CONTEXT_MAX_CHARS:,} chars; truncating for full_context extraction." + ) + contract_text = contract_text[:_FULL_CONTEXT_MAX_CHARS] + + for i in range(0, len(all_fields), _FULL_CONTEXT_BATCH_SIZE): + batch = all_fields[i : i + _FULL_CONTEXT_BATCH_SIZE] + prompt_dict = { + f.field_name: overrides.get(f.field_name) or f.get_prompt(constants) + for f in batch + } + prompt = prompt_templates.ONE_TO_ONE_MULTI_FIELD_TEMPLATE( + contract_text, prompt_dict + ) + raw = llm_utils.invoke_claude( + prompt, + "sonnet_latest", + filename, + max_tokens=8192, + cache=True, + instruction=prompt_templates.ONE_TO_ONE_MULTI_FIELD_INSTRUCTION(), + usage_label=f"{usage_prefix}_FULL_CONTEXT", + ) + batch_result = string_utils.universal_json_load(raw) + if not isinstance(batch_result, dict): + logging.warning( + f"[{filename}] Unexpected LLM response format for full_context batch " + f"{i // _FULL_CONTEXT_BATCH_SIZE + 1} " + f"(fields {[f.field_name for f in batch]}): {raw[:200]}" + ) + else: + results.update(batch_result) + + return results + + +# --------------------------------------------------------------------------- +# value_derived (secondary LLM using extracted values as context, not full doc) +# --------------------------------------------------------------------------- + + +def run_value_derived_fields( + fields: FieldSet, + answers: dict, + filename: str, + usage_prefix: str = "VENDOR", + constants=None, +) -> dict: + """Run focused LLM calls for value_derived fields. + + Unlike full_context fields, value_derived fields do not receive the full + contract text. Their context is built solely from the already-extracted + values of their depends_on_list (or depends_on) fields. This mirrors the + old behaviour of secondary LLM calls that operated on extracted snippets + (e.g. extracting a section number from an already-extracted clause text). + + Each field gets its own LLM call since dependency sets differ per field. + + Args: + fields: FieldSet of value_derived fields to process. + answers: Already-extracted answers dict (provides dependency values). + filename: File being processed. + usage_prefix: Prefix for usage_label. + constants: Constants instance for prompt placeholder resolution. + + Returns: + dict of {field_name: extracted_value} + """ + if not fields.contains_fields(): + return {} + + results = {} + for field in fields.fields: + dep_list = field.depends_on_list or ( + [field.depends_on] if field.depends_on else [] + ) + if not dep_list: + logging.warning( + f"[{filename}] value_derived field '{field.field_name}' has no " + f"depends_on / depends_on_list — skipping." + ) + continue + + # Build a small context from the dependency values + context_parts = [f"{dep}: {answers.get(dep) or 'Unknown'}" for dep in dep_list] + context = "\n".join(context_parts) + + prompt_text = field.get_prompt(constants) or "" + prompt = prompt_templates.ONE_TO_ONE_SINGLE_FIELD_TEMPLATE( + context, {field.field_name: prompt_text} + ) + raw = llm_utils.invoke_claude( + prompt, + "sonnet_latest", + filename, + max_tokens=1024, + cache=False, + instruction=prompt_templates.ONE_TO_ONE_SINGLE_FIELD_INSTRUCTION(), + usage_label=f"{usage_prefix}_VALUE_DERIVED", + ) + result = string_utils.universal_json_load(raw) + if isinstance(result, dict) and field.field_name in result: + results[field.field_name] = result[field.field_name] + elif isinstance(result, str): + results[field.field_name] = result + else: + logging.warning( + f"[{filename}] Unexpected response for value_derived field " + f"'{field.field_name}': {raw[:200]}" + ) + + return results + + +# --------------------------------------------------------------------------- +# bottom_up_page_level +# --------------------------------------------------------------------------- + + +def run_bottom_up_pages( + text_dict: dict, + template_fn, + instruction_fn, + payer: str, + filename: str, + usage_label: str, + page_filter=None, + max_workers: int = 20, +) -> list: + """Run bottom-up extraction across pages in parallel. + + Args: + text_dict: page_number → page_text mapping. + template_fn: Template function (e.g. prompt_templates.BOTTOM_UP_PRIMARY). + instruction_fn: Corresponding _INSTRUCTION function for caching. + payer: Payer name injected into the prompt. + filename: File being processed. + usage_label: Label for usage tracking. + page_filter: Optional callable(page_text) → bool. Pages where this + returns False are skipped. If None, all pages are processed. + max_workers: Max parallel threads. + + Returns: + list of row dicts (each row has PAGE_NUM added). + """ + page_items = list(text_dict.items()) + effective_workers = min(len(page_items), max_workers) + rows: list[dict] = [] + + def process_page(page_num: str, page_text: str) -> list: + if page_filter is not None and not page_filter(page_text): + return [] + prompt = template_fn(page_text, payer) + raw = llm_utils.invoke_claude( + prompt, + "sonnet_latest", + filename, + max_tokens=16384, + cache=True, + instruction=instruction_fn(), + usage_label=usage_label, + ) + try: + result = string_utils.universal_json_load(raw) + except ValueError: + logging.warning( + f"[{filename}] Page {page_num}: Failed to parse bottom-up response: " + f"{raw[:200]}" + ) + return [] + + if not isinstance(result, list): + logging.warning( + f"[{filename}] Page {page_num}: Expected JSON array, got " + f"{type(result).__name__}: {raw[:200]}" + ) + return [] + + for item in result: + item["PAGE_NUM"] = page_num + return result + + with concurrent.futures.ThreadPoolExecutor( + max_workers=effective_workers + ) as executor: + futures = {executor.submit(process_page, pn, pt): pn for pn, pt in page_items} + for future in concurrent.futures.as_completed(futures): + try: + page_rows = future.result() + rows.extend(page_rows) + except Exception as exc: + page_num = futures[future] + logging.error( + f"[{filename}] Error processing page {page_num} in bottom-up: {exc}" + ) + + return rows diff --git a/src/pipelines/vendors/shared/generic_processor.py b/src/pipelines/vendors/shared/generic_processor.py new file mode 100644 index 0000000..93ef317 --- /dev/null +++ b/src/pipelines/vendors/shared/generic_processor.py @@ -0,0 +1,479 @@ +""" +Generic Vendor Processor + +Single central processing unit for all vendor pipelines. +Each vendor is driven entirely by: + - src/prompts/vendor_fields.json (what fields to extract and how) + - pipelines/vendors//config.yaml (payer name, derived indicators, etc.) + +Output structure: + - Flat CSV (same as saas): 1-to-1 fields broadcast into each SLA/KPI row so every + output row is a complete record. If no SLA/KPI rows exist, a single 1-to-1 row is + written. + +VENDOR_SERVICES (B-ServiceGroup) is intentionally not run; service description is +captured via the DESCRIPTION_OF_SERVICES one_to_one field instead. + +Adding a new vendor requires only: + 1. Add fields to vendor_fields.json with "vendors": ["new_vendor"] + 2. Create pipelines/vendors/new_vendor/config.yaml with payer_name etc. + 3. No Python file needed. +""" + +import logging +from pathlib import Path + +import pandas as pd +import yaml + +import src.pipelines.vendors.prompt_templates as prompt_templates +import src.utils.string_utils as string_utils +import src.utils.timing_utils as timing_utils +from src import config +from src.pipelines.shared.preprocessing import preprocess +from src.pipelines.vendors.shared import extraction +from src.prompts.fieldset import FieldSet +from src.utils import io_utils, logging_utils +from src.utils.string_utils import datetime_str + +_VENDOR_FIELDS_PATH = str( + Path(__file__).parent.parent.parent.parent / "prompts" / "vendor_fields.json" +) + +_VENDORS_BASE_PATH = Path(__file__).parent.parent # pipelines/vendors/ + +# Fixed schema for the SLA/KPI tab — same columns across all vendors +_SLA_KPI_COLUMNS: list[str] = [ + "FILE_NAME", + "METRIC_NAME", + "PURPOSE", + "MEASUREMENT", + "ASSUMPTIONS", + "STANDARDS", + "REPORTING_FREQUENCY", + "AMENDMENT_TYPE", + "PAGE_NUM", +] + + +def _is_empty_val(val) -> bool: + if val is None: + return True + return str(val).strip().lower() in ("", "n/a", "na", "none", "null") + + +def _derive_indicators(df: pd.DataFrame, derived_fields: list[str]) -> pd.DataFrame: + """Add _IND = 'Y'/'N' columns derived from text field presence.""" + for field in derived_fields: + ind_col = field + "_IND" + if field in df.columns: + df[ind_col] = df[field].apply( + lambda v: "Y" if not _is_empty_val(v) else "N" + ) + else: + df[ind_col] = "N" + return df + + +def _normalize_ind_columns(df: pd.DataFrame) -> pd.DataFrame: + """Normalize all *_IND columns to strict 'Y' / 'N'.""" + for col in df.columns: + if "_IND" in col: + df[col] = df[col].apply( + lambda v: "Y" if str(v).strip() in ("Y", "Yes", "y", "yes") else "N" + ) + return df + + +def _clear_auto_renewal_for_fixed_term(df: pd.DataFrame) -> pd.DataFrame: + """Set AUTO_RENEWAL_PERIOD to N/A when TERM_TYPE is Fixed.""" + if "TERM_TYPE" not in df.columns or "AUTO_RENEWAL_PERIOD" not in df.columns: + return df + is_fixed = df["TERM_TYPE"].apply(lambda v: str(v).strip() == "Fixed") + df.loc[is_fixed, "AUTO_RENEWAL_PERIOD"] = "N/A" + return df + + +def _clear_payment_type_without_amount(df: pd.DataFrame) -> pd.DataFrame: + """Set PAYMENT_TYPE to N/A when AMOUNT has no extractable value. + + The LLM returns NOT_FOUND for AMOUNT when no dollar figure is present. + A populated PAYMENT_TYPE with no corresponding amount is meaningless, so + we nullify it here as a universal post-processing step. + """ + if "PAYMENT_TYPE" not in df.columns or "AMOUNT" not in df.columns: + return df + no_amount = df["AMOUNT"].apply( + lambda v: _is_empty_val(v) or str(v).strip().upper() == "NOT_FOUND" + ) + df.loc[no_amount, "PAYMENT_TYPE"] = "N/A" + return df + + +def _ensure_columns( + df: pd.DataFrame, columns: list[str], fill: str = "N/A" +) -> pd.DataFrame: + """Add any missing expected columns, filled with `fill`.""" + missing = [c for c in columns if c not in df.columns] + if missing: + df = df.copy() + for col in missing: + df[col] = fill + return df + + +class VendorProcessor: + """ + Generic vendor processor driven by config.yaml + vendor_fields.json. + + Matches the interface expected by runner.py: + - process_file(file_object, constants, run_timestamp) -> pd.DataFrame + - SHEET_COLUMN_SCHEMAS -> dict[str, list[str]] + """ + + def __init__(self, vendor_name: str): + self.vendor_name = vendor_name + self._cfg = self._load_config() + self._one_to_one_columns: list[str] | None = None # lazily computed + + # ------------------------------------------------------------------ + # Config accessors + # ------------------------------------------------------------------ + + def _load_config(self) -> dict: + config_path = _VENDORS_BASE_PATH / self.vendor_name / "config.yaml" + if not config_path.exists(): + raise FileNotFoundError( + f"No config.yaml found for vendor '{self.vendor_name}' " + f"at {config_path}" + ) + with open(config_path) as f: + return yaml.safe_load(f) or {} + + @property + def payer_name(self) -> str: + return self._cfg.get("payer_name", self.vendor_name) + + @property + def usage_prefix(self) -> str: + return self._cfg.get("usage_prefix", self.vendor_name.upper().replace("-", "_")) + + @property + def derived_indicators(self) -> list[str]: + """Fields whose non-empty value generates a companion _IND column.""" + return self._cfg.get("derived_indicators", []) + + @property + def indicator_only_fields(self) -> list[str]: + """Fields extracted for _IND derivation only; excluded from output columns.""" + return self._cfg.get("indicator_only_fields", []) + + @property + def vendor_filter(self) -> str | None: + """ + Vendor name to pass to FieldSet for filtering, or None to load all fields. + + None is used by the 'generic' client: every field in vendor_fields.json + is extracted regardless of its vendors list. This is useful for running + a contract without knowing which vendor it belongs to upfront. + """ + # config.yaml can explicitly set vendor_filter: null to disable filtering + if "vendor_filter" in self._cfg and self._cfg["vendor_filter"] is None: + return None + return self.vendor_name + + # ------------------------------------------------------------------ + # Column schema (auto-derived from vendor_fields.json) + # ------------------------------------------------------------------ + + def _build_one_to_one_columns(self) -> list[str]: + """ + Derive the ordered 1-to-1 column list from vendor_fields.json. + + If config.yaml defines a `column_order` list, that order is used directly. + + Default order (no column_order in config): + FILE_NAME, NUM_PAGES, + [for each one_to_one field in JSON order]: + - field_name (unless it's indicator_only) + - field_name_IND (if it's in derived_indicators) + CLIENT_NAME + """ + if "column_order" in self._cfg and self._cfg["column_order"]: + return list(self._cfg["column_order"]) + + kwargs = {"relationship": "one_to_one"} + if self.vendor_filter is not None: + kwargs["vendor"] = self.vendor_filter + fields = FieldSet(file_path=_VENDOR_FIELDS_PATH, **kwargs) + + cols = ["FILE_NAME", "NUM_PAGES"] + for field in fields.fields: + fn = field.field_name + if fn not in self.indicator_only_fields: + cols.append(fn) + if fn in self.derived_indicators: + cols.append(f"{fn}_IND") + cols.append("CLIENT_NAME") + + return cols + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def process_file(self, file_object, constants, run_timestamp) -> pd.DataFrame: + """Process a single vendor contract file. + + Args: + file_object: Tuple of (filename, contract_text). + constants: Constants instance. + run_timestamp: Timestamp string for output naming. + + Returns: + Flat DataFrame combining 1-to-1 and SLA/KPI rows (broadcast style). + Each SLA/KPI row carries all 1-to-1 field values, matching saas behaviour. + """ + filename, contract_text = file_object + logging_utils.set_current_file(filename) + logging.info(f"{datetime_str()} [{self.vendor_name}] Processing {filename}...") + + process_one_to_one = config.FIELDS in ["all", "one_to_one"] + process_one_to_n = config.FIELDS in ["all", "one_to_n"] + + with timing_utils.timed_block("preprocess", context=filename): + contract_text = preprocess.clean_text(contract_text) + text_dict, _ = preprocess.split_text(contract_text) + text_dict, _, _ = preprocess.find_headers_and_footers(text_dict) + + one_to_one_row: dict = {} + sla_df = pd.DataFrame() + + # -------- one-to-one -------- + if process_one_to_one: + with timing_utils.timed_block("one_to_one_extraction", context=filename): + results = self._run_one_to_one( + contract_text, text_dict, filename, constants=constants + ) + results["FILE_NAME"] = filename + results["NUM_PAGES"] = len(text_dict) + logging.info( + f"{datetime_str()} [{self.vendor_name}] One-to-One complete — {filename}" + ) + + if self._one_to_one_columns is None: + self._one_to_one_columns = self._build_one_to_one_columns() + + ac_df = self._postprocess(pd.DataFrame([results])) + ac_df = _ensure_columns(ac_df, self._one_to_one_columns) + ac_df = ac_df[self._one_to_one_columns] + one_to_one_row = ac_df.iloc[0].to_dict() + + # -------- one-to-n: SLA/KPI only (no service group) -------- + if process_one_to_n: + with timing_utils.timed_block("one_to_n_extraction", context=filename): + sla_df = self._run_sla_kpis(text_dict, filename) + logging.info( + f"{datetime_str()} [{self.vendor_name}] One-to-N complete — {filename}" + ) + + if not sla_df.empty: + sla_df["FILE_NAME"] = filename + sla_df = _ensure_columns(sla_df, _SLA_KPI_COLUMNS) + sla_df = sla_df[_SLA_KPI_COLUMNS] + + # -------- combine: broadcast 1-to-1 values into each SLA/KPI row -------- + if not sla_df.empty and one_to_one_row: + # Each SLA row gets all 1-to-1 field values (1-to-1 takes precedence + # for any shared key such as FILE_NAME) + flat_df = pd.DataFrame( + [ + {**sla_row.to_dict(), **one_to_one_row} + for _, sla_row in sla_df.iterrows() + ] + ) + elif one_to_one_row: + flat_df = pd.DataFrame([one_to_one_row]) + elif not sla_df.empty: + flat_df = sla_df.copy() + else: + flat_df = pd.DataFrame([{"FILE_NAME": filename}]) + + with timing_utils.timed_block("write_individual", context=filename): + if config.WRITE_TO_S3: + io_utils.write_s3(flat_df, filename, run_timestamp, "individual") + else: + io_utils.write_local(flat_df, filename, "", "individual") + logging.info( + f"{datetime_str()} [{self.vendor_name}] Writing complete — {filename}" + ) + return flat_df + + # ------------------------------------------------------------------ + # Extraction helpers + # ------------------------------------------------------------------ + + def _run_one_to_one( + self, contract_text: str, text_dict: dict, filename: str, constants=None + ) -> dict: + """Run all one_to_one extraction strategies for this vendor.""" + kwargs = {"relationship": "one_to_one"} + if self.vendor_filter is not None: + kwargs["vendor"] = self.vendor_filter + one_to_one_fields = FieldSet(file_path=_VENDOR_FIELDS_PATH, **kwargs) + + smart_fields = one_to_one_fields.filter(field_type="smart_chunking") + full_context_fields = one_to_one_fields.filter(field_type="full_context") + full_context_single_fields = one_to_one_fields.filter( + field_type="full_context_single" + ) + value_derived_fields = one_to_one_fields.filter(field_type="value_derived") + + answers = {} + + # 1. Smart chunking (keyword-targeted) + with timing_utils.timed_block("one_to_one.smart_chunking", context=filename): + smart_answers = extraction.run_smart_chunked_fields( + smart_fields, text_dict, filename, usage_prefix=self.usage_prefix + ) + answers.update(smart_answers) + + # 2. Full context — unfilled smart + all full_context fields (includes PAYMENT_TYPE) + with timing_utils.timed_block("one_to_one.full_context", context=filename): + remaining = FieldSet( + fields=[ + f + for f in full_context_fields.fields + if string_utils.is_empty(answers.get(f.field_name)) + ] + ) + for f in smart_fields.fields: + if string_utils.is_empty(answers.get(f.field_name)): + remaining.add_field(f) + + full_answers = extraction.run_full_context_fields( + contract_text, + remaining, + filename, + usage_prefix=self.usage_prefix, + constants=constants, + ) + for key, val in full_answers.items(): + if string_utils.is_empty(answers.get(key)): + answers[key] = val + + # 3. Full context single — one field per LLM call. + # All fields sent together in one batched call. Fields with depends_on + # (e.g. AMOUNT → PAYMENT_TYPE) have their dependency value prepended to + # their prompt inline so they stay in the same batch as the others. + with timing_utils.timed_block( + "one_to_one.full_context_single", context=filename + ): + unfilled_singles = FieldSet( + fields=[ + f + for f in full_context_single_fields.fields + if string_utils.is_empty(answers.get(f.field_name)) + ] + ) + if unfilled_singles.contains_fields(): + prompt_overrides = {} + for f in unfilled_singles.fields: + dep_fields = f.depends_on_list or ( + [f.depends_on] if f.depends_on else [] + ) + if dep_fields: + preamble = "\n".join( + f"The {dep} for this contract has been identified as: " + f"{answers.get(dep) or 'Unknown'}." + for dep in dep_fields + ) + prompt_overrides[f.field_name] = ( + preamble + "\n\n" + (f.get_prompt(constants) or "") + ) + single_answers = extraction.run_full_context_fields( + contract_text, + unfilled_singles, + filename, + usage_prefix=self.usage_prefix, + prompt_overrides=prompt_overrides, + constants=constants, + ) + for key, val in single_answers.items(): + if string_utils.is_empty(answers.get(key)): + answers[key] = val + + # 4. Value derived — secondary LLM calls using extracted values as context. + # Each field receives only its dependency values (no full contract text), + # matching the old per-field focused calls for fields like + # Termination_Clause_Section_Num and Derived_Initial_ExpirationDate. + with timing_utils.timed_block("one_to_one.value_derived", context=filename): + unfilled_derived = FieldSet( + fields=[ + f + for f in value_derived_fields.fields + if string_utils.is_empty(answers.get(f.field_name)) + ] + ) + derived_answers = extraction.run_value_derived_fields( + unfilled_derived, + answers, + filename, + usage_prefix=self.usage_prefix, + constants=constants, + ) + for key, val in derived_answers.items(): + if string_utils.is_empty(answers.get(key)): + answers[key] = val + + return answers + + def _run_sla_kpis(self, text_dict: dict, filename: str) -> pd.DataFrame: + """Run bottom-up SLA/KPI extraction if this vendor has VENDOR_SLAS_KPIS.""" + one_to_n_kwargs = {"relationship": "one_to_n"} + if self.vendor_filter is not None: + one_to_n_kwargs["vendor"] = self.vendor_filter + one_to_n = FieldSet(file_path=_VENDOR_FIELDS_PATH, **one_to_n_kwargs) + if not any(f.field_name == "VENDOR_SLAS_KPIS" for f in one_to_n.fields): + return pd.DataFrame() + + rows = extraction.run_bottom_up_pages( + text_dict=text_dict, + template_fn=prompt_templates.BOTTOM_UP_SLA_KPI, + instruction_fn=prompt_templates.BOTTOM_UP_SLA_KPI_INSTRUCTION, + payer=self.payer_name, + filename=filename, + usage_label=f"{self.usage_prefix}_BOTTOM_UP_SLA_KPI", + page_filter=None, + ) + return pd.DataFrame(rows) if rows else pd.DataFrame() + + def _apply_static_fields(self, df: pd.DataFrame) -> pd.DataFrame: + """Set columns to fixed values defined in config.yaml under static_fields.""" + for col, val in self._cfg.get("static_fields", {}).items(): + df[col] = val + return df + + def _apply_derived_mappings(self, df: pd.DataFrame) -> pd.DataFrame: + """Populate columns derived from other columns via config.yaml derived_mappings.""" + for mapping_cfg in self._cfg.get("derived_mappings", []): + out_col = mapping_cfg["output_field"] + src_col = mapping_cfg["source_field"] + mapping = mapping_cfg.get("mapping", {}) + if src_col in df.columns: + df[out_col] = df[src_col].map(mapping).fillna("N/A") + else: + df[out_col] = "N/A" + return df + + def _postprocess(self, df: pd.DataFrame) -> pd.DataFrame: + """Add CLIENT_NAME, derive _IND columns, normalize Y/N, apply static and mapped fields.""" + df = df.copy() + df["CLIENT_NAME"] = self.vendor_name + df = _derive_indicators(df, self.derived_indicators) + df = _normalize_ind_columns(df) + df = _clear_payment_type_without_amount(df) + df = _clear_auto_renewal_for_fixed_term(df) + df = self._apply_static_fields(df) + df = self._apply_derived_mappings(df) + return df diff --git a/src/prompts/fieldset.py b/src/prompts/fieldset.py index e3373b5..df1e21e 100644 --- a/src/prompts/fieldset.py +++ b/src/prompts/fieldset.py @@ -76,6 +76,13 @@ class Field: if "retrieval_question" in field_dict else None ) + # Multi-vendor Attributes + self.vendors = field_dict.get("vendors", []) + # Joint Extraction Attributes + self.joint_group = field_dict.get("joint_group", None) + # Context-Dependent Extraction Attributes + self.depends_on = field_dict.get("depends_on", None) + self.depends_on_list = field_dict.get("depends_on_list", None) def __repr__(self): """Returns a string representation of the Field object.""" @@ -263,6 +270,14 @@ class FieldSet: matches_all_filters = True for attr, filter_value in filters.items(): + # Special case: "vendor" filter checks membership in the vendors list + if attr == "vendor": + vendors_list = field_dict.get("vendors", []) + if filter_value not in vendors_list: + matches_all_filters = False + break + continue + field_value = field_dict.get(attr) # Handle special case for True filter (check if attribute exists) diff --git a/src/prompts/prompt_templates.py b/src/prompts/prompt_templates.py index 568795a..face582 100644 --- a/src/prompts/prompt_templates.py +++ b/src/prompts/prompt_templates.py @@ -1825,6 +1825,48 @@ Note: PROVIDER NAME B is typically a single name but may contain multiple names. return (prompt, _json_list_parser) +def JOINT_FIELD_TEMPLATE( + context: str, questions: dict[str, str], consistency_rule: str = "" +) -> str: + rule_section = ( + f"\n\nCONSISTENCY RULES — these fields must be logically consistent with each other:\n{consistency_rule}\n" + if consistency_rule + else "" + ) + return f"""The following is a contract (or excerpt thereof). Answer the following questions: {questions}{rule_section} + +## START CONTRACT TEXT ## +{context.replace('"', "'")} +## END CONTRACT TEXT ## + +Reason through each answer carefully, ensuring all values are consistent with each other before finalizing. Explanation MUST NOT contain any curly brackets '{{' or '}}'. Replace them with '()' instead. + +The JSON should: +- Include every key +- Use 'N/A' for any question where the requested information doesn't apply to this context +- Use 'NOT_FOUND' where explicitly instructed +- Contain only the final answers, not explanations + +Example format: +I analyzed the payment structure and found... +Given the monthly rate and fixed term, I will calculate... + +{{ + "PAYMENT_TYPE": "Monthly", + "AMOUNT": "5000" +}} +""" + + +def JOINT_FIELD_INSTRUCTION() -> str: + return ( + "[OBJECTIVE]\n" + "Extract multiple related fields from a contract in a single pass, ensuring the values are " + "logically consistent with each other. Apply all consistency rules in the prompt before " + "finalizing your answers. Follow JSON-only output rules." + ) + + def EXTRACT_AMENDMENT_NUM_FROM_FILENAME(filename) -> Tuple[str, Callable[[str], dict]]: """ Returns prompt and parser for extracting amendment number from filename. diff --git a/src/prompts/vendor_fields.json b/src/prompts/vendor_fields.json new file mode 100644 index 0000000..10b4831 --- /dev/null +++ b/src/prompts/vendor_fields.json @@ -0,0 +1,809 @@ +[ + { + "field_name": "TERMINATION_DATE", + "vendors": [ + "la_care", + "sg_a", + "bcbs_arizona", + "care_source", + "generic" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "Return term end date for this contract. Look for it in the following priority order:\n1. If an explicit end date is provided, use that.\n2. If only a duration is provided, calculate the end date by adding the full duration to the effective date of this document. Use the following instructions to determine the effective date:\n If this contract is made part of an MSA dated [DATE], DO NOT use that date. Instead, use the 'SOW Effective Date'. If the 'SOW Effective Date' or 'Amendment Effective Date' is the Second Signature Date, then please do not use the date when the MSA was dated--which is typically present in the first paragraph of the contract, instead look for a date on the signature page. If no date is present on the signature page, just return N/A.\n3. If the contract states that the order acceptance date is to be used as the effective date, then use the CUSTOMER'S order acceptance date\u2014not the vendor's.\nWhen calculating the end date, add the full duration strictly, ignoring leap year variations. If the resulting date does not exist (e.g., February 30), use the last valid date of that month/year.\nReturn only the calculated end date in YYYY-MM-DD format. If the end date cannot be determined, return N/A.", + "keywords": [ + "TERM AND TERMINATION", + "Term and Termination", + "Term and termination", + "term and termination", + "Term", + "TERM", + "Renew", + "renew", + "Auto renew", + "auto renew", + "automatically renew", + "evergreen", + "Evergreen", + "upon notice", + "Notice", + "NOTICE", + "with notice", + "without cause", + "With Cause", + "with cause", + "Termination", + "Terminate", + "Effective Date" + ], + "methodology": "or", + "case_sensitive": true + }, + { + "field_name": "AUTO_RENEW_IND", + "vendors": [ + "la_care", + "sg_a", + "bcbs_arizona" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "Search the Term or Duration section of the contract for automatic renewal language, return 'Yes' if the contract has provisions to automatically continue at term end (using phrases like 'auto renew', 'auto-renewal', 'automatically extends', 'evergreen' or similar renewal terms), otherwise make sure to return 'No'. Reply with either Yes or No, do not return N/A.", + "keywords": [ + "TERM AND TERMINATION", + "Term and Termination", + "Term and termination", + "term and termination", + "Term", + "TERM", + "Renew", + "renew", + "Auto renew", + "auto renew", + "automatically renew", + "evergreen", + "Evergreen", + "upon notice", + "Notice", + "NOTICE", + "with notice", + "without cause", + "With Cause", + "with cause", + "Termination", + "Terminate", + "Effective Date" + ], + "methodology": "or", + "case_sensitive": true + }, + { + "field_name": "TERMINATION_RIGHT", + "vendors": [ + "la_care", + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "If this contract includes termination for convenience (without cause) rights, identify which party or parties hold this right. Return the full legal name of the entity with this right, 'Both' if both or either parties have the right, or 'N/A' if no such rights are found. Only include parties whose termination for convenience rights are explicitly stated, do not infer rights if they're not clearly granted in the contract language return 'N/A'.", + "keywords": [ + "TERM AND TERMINATION", + "Term and Termination", + "Term and termination", + "term and termination", + "Term", + "TERM", + "Renew", + "renew", + "Auto renew", + "auto renew", + "automatically renew", + "evergreen", + "Evergreen", + "upon notice", + "Notice", + "NOTICE", + "with notice", + "without cause", + "With Cause", + "with cause", + "Termination", + "Terminate", + "Effective Date" + ], + "methodology": "or", + "case_sensitive": true + }, + { + "field_name": "TERMINATION_NOTICE_PERIOD", + "vendors": [ + "la_care", + "sg_a", + "bcbs_arizona", + "care_source", + "generic" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "If either party is allowed to terminate this contract, what is the notice period required to terminate? Return the value with its unit, such as '30 days', '60 days', '90 days', '6 months', etc. DO NOT return the notice period for 'Non Renewal'. Return N/A if not present.", + "keywords": [ + "TERM AND TERMINATION", + "Term and Termination", + "Term and termination", + "term and termination", + "Term", + "TERM", + "Renew", + "renew", + "Auto renew", + "auto renew", + "automatically renew", + "evergreen", + "Evergreen", + "upon notice", + "Notice", + "NOTICE", + "with notice", + "without cause", + "With Cause", + "with cause", + "Termination", + "Terminate", + "Effective Date" + ], + "methodology": "or", + "case_sensitive": true + }, + { + "field_name": "TERMINATION_PENALTY", + "vendors": [ + "la_care", + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "Return any language that refers to a penalty, fee, cost, or obligation incurred as a result of terminating the contract. This language is typically found in the Termination section but may appear elsewhere. If no such penalty is mentioned, return N/A.", + "keywords": [ + "TERM AND TERMINATION", + "Term and Termination", + "Term and termination", + "term and termination", + "Term", + "TERM", + "Renew", + "renew", + "Auto renew", + "auto renew", + "automatically renew", + "evergreen", + "Evergreen", + "upon notice", + "Notice", + "NOTICE", + "with notice", + "without cause", + "With Cause", + "with cause", + "Termination", + "Terminate", + "Effective Date" + ], + "methodology": "or", + "case_sensitive": true + }, + { + "field_name": "INDEMNIFICATION", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "If indemnification language is present in the contract, identify whether the vendor, LA Care, or both are being indemnified. Return only the party names or roles (vendor/client), not related entities like officers, agents, or employees.", + "keywords": [ + "Indemnity", + "Indemnification", + "INDEMNIFICATION", + "Limitation of Liability", + "LIMITATION OF LIABILITY", + "INDEMNIFICATION AND LIMITATION OF LIABILITY", + "Warranty", + "Disclaimer", + "WARRANTY DISCLAIMER" + ], + "methodology": "or", + "case_sensitive": true + }, + { + "field_name": "INDEMNIFICATION_CLAUSE", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "Return Indemnification or Indemnity clause or language from this contract, return the entire clause or language with all its sub-sections.", + "keywords": [ + "Indemnity", + "Indemnification", + "INDEMNIFICATION", + "Limitation of Liability", + "LIMITATION OF LIABILITY", + "INDEMNIFICATION AND LIMITATION OF LIABILITY", + "Warranty", + "Disclaimer", + "WARRANTY DISCLAIMER" + ], + "methodology": "or", + "case_sensitive": true + }, + { + "field_name": "LIMITATION_OF_LIABILITY", + "vendors": [ + "la_care", + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "Return the entire Limitation of Liability clause or paragraph from the contract, including any sub-sections. If multiple limitations are defined for different parties, include all of them. If not present, return N/A.", + "keywords": [ + "Indemnity", + "Indemnification", + "INDEMNIFICATION", + "Limitation of Liability", + "LIMITATION OF LIABILITY", + "INDEMNIFICATION AND LIMITATION OF LIABILITY", + "Warranty", + "Disclaimer", + "WARRANTY DISCLAIMER" + ], + "methodology": "or", + "case_sensitive": true + }, + { + "field_name": "WARRANTY_CLAUSE", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "If Warranty clause or Warranty Disclaimer is present in contract, return the entire clause with all its sub-sections. If not present, return N/A.", + "keywords": [ + "Indemnity", + "Indemnification", + "INDEMNIFICATION", + "Limitation of Liability", + "LIMITATION OF LIABILITY", + "INDEMNIFICATION AND LIMITATION OF LIABILITY", + "Warranty", + "Disclaimer", + "WARRANTY DISCLAIMER" + ], + "methodology": "or", + "case_sensitive": true + }, + { + "field_name": "BAA_REQUIRED_IND", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "Does the contract indicate that a Business Associate Agreement or Business Associate Addendum is required for the agreement to go into effect? Answer ONLY with 'Y' if it does and 'N' if it does not, don't return any other context and don't return 'N/A'.", + "keywords": [ + "Business Associate Agreement", + "BAA", + "Business Associate", + "BA Agreement", + "Business Associate Addendum" + ], + "methodology": "or", + "case_sensitive": false + }, + { + "field_name": "BAA_LAST_UPDATE", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "If the contract has a Business Associate Agreement or Business Associate Addendum (BAA), return the date when BAA was last updated. This may be the same as the contract effective date. If not applicable, return 'N/A'.", + "keywords": [ + "Business Associate Agreement", + "BAA", + "Business Associate", + "BA Agreement", + "Business Associate Addendum" + ], + "methodology": "or", + "case_sensitive": false + }, + { + "field_name": "BAA_CONTACT_NAME", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "Identify the primary point of contact from L.A Care for the Business Associate Addendum or Business Associate Agreement (BAA). This may be the Contracting Officer (CO), Program Officer (PO), Chief Information Security Officer (CISO), Chief Financial Officer (CFO), or Contract Manager. Their name will be present on the BAA signatory page as the L.A. Care representative.", + "keywords": [ + "Business Associate Agreement", + "BAA", + "Business Associate", + "BA Agreement", + "Business Associate Addendum" + ], + "methodology": "or", + "case_sensitive": false + }, + { + "field_name": "BAA_IND", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "Is there an entire section for Business Associate Agreement or Business Associate Addendum (BAA) included in the agreement? Answer ONLY with 'Y' if yes, and 'N' if no.", + "keywords": [ + "Business Associate Agreement", + "BAA", + "Business Associate", + "BA Agreement", + "Business Associate Addendum" + ], + "methodology": "or", + "case_sensitive": false + }, + { + "field_name": "BAA_END_DT", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "Return the language which specifies when the BAA will end or terminate. It is typically found in the Term section.", + "keywords": [ + "Business Associate Agreement", + "BAA", + "Business Associate", + "BA Agreement", + "Business Associate Addendum" + ], + "methodology": "or", + "case_sensitive": false + }, + { + "field_name": "BAA_EFFECTIVE_DT", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "smart_chunking", + "prompt": "Return the effective date of the Business Associate Agreement or Business Associate Addendum (BAA) \u2014 the date the BAA went into effect. Return the date in YYYY-MM-DD format. If not applicable, return 'N/A'.", + "keywords": [ + "Business Associate Agreement", + "BAA", + "Business Associate", + "BA Agreement", + "Business Associate Addendum" + ], + "methodology": "or", + "case_sensitive": false + }, + { + "field_name": "PAYER_OR_VENDOR_PAPER", + "vendors": [ + "la_care", + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Determine which entity authored this contract. Return the payer/health plan entity name if the contract uses their template, or 'Vendor' if the contract uses the vendor's template. Indicator: the payer typically appears before the Vendor in the preamble if it's their paper." + }, + { + "field_name": "EFFECTIVE_DATE", + "vendors": [ + "la_care", + "sg_a", + "bcbs_arizona", + "care_source", + "generic" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "What is the effective start date of this contract? If the contract is an Amendment, Exhibit, or SOW use the signature date, if it represents the effective date. DO NOT return the effective date of the document that is amended. Return the date converted to YYYY-MM-DD format, with no other commentary or explanation. If there is no suitable effective start date, simply return 'N/A'." + }, + { + "field_name": "STATUS", + "vendors": [ + "la_care", + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Describe the status of the contract. Based on today's date, is the contract active, terminated, or expired? If the current date is before the expiration date, or if the current date is after the expiration date but within the interval defined by any auto-renewal language, the contract is active. If it is beyond the expiration date and there is no auto-renewal language, it is expired. If it is mentioned in the agreement that termination for convenience or any other premature termination was initiated, it is terminated." + }, + { + "field_name": "PARENT_CONTRACT_DT", + "vendors": [ + "la_care", + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Return the effective date of the Master Services Agreement (MSA) if mentioned anywhere in the document. ONLY if MSA is not mentioned return the effective date of the Agreement which is being amended or modified by this document. Look for phrases like 'entered into by and between the parties...' to identify the original agreement. Return the date in MM/DD/YYYY format only." + }, + { + "field_name": "PERFORMANCE_GUARANTEE_VALUE", + "vendors": [ + "la_care", + "sg_a", + "bcbs_arizona" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Does the contract contain performance guarantee provisions, service level agreement (SLA) credits, penalties, or liquidated damages tied to performance failures? Look for clauses specifying financial consequences for missed performance targets, service levels, or deliverables. This may include: credit amounts, percentage-based penalties (e.g., '150% of total allocation'), fixed penalty amounts, or cumulative guarantee values. If such provisions exist, extract and return the entire paragraph(s) or clause(s) verbatim that contain these performance guarantee provisions. Include all relevant text that describes the guarantee structure, penalty tiers, calculation methods, and conditions. If multiple paragraphs address performance guarantees, extract all of them in their entirety. If no performance guarantee, SLA credits, or penalty provisions are present, return 'N/A'." + }, + { + "field_name": "AUDIT_CLAUSE_IND", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Is Audit clause present in contract language? The answer is usually found in section 2.13 but might not always be present there. Answer ONLY with Y or N. Do NOT write N/A." + }, + { + "field_name": "FORCE_MAJEURE_IND", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Is there a Force Majeure clause or similar language present in the contract? Answer ONLY with Y or N. Do NOT write N/A." + }, + { + "field_name": "ASSIGNMNET_CLAUSE", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "If Nonassignability and/or Assignment clause is present in contract, return the entire clause or paragraph. If not present, return N/A." + }, + { + "field_name": "AMENDMENT_OR_CHANGE_ORDER", + "vendors": [ + "la_care", + "sg_a", + "bcbs_arizona", + "generic" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Is the contract an amendment or change order \u2014 but not a Statement of Work (SWO) \u2014 to a previous agreement? If yes, return 'Y'. If not, return 'N'. Do not return 'N/A'." + }, + { + "field_name": "VENDOR_CONTACTS", + "vendors": [ + "la_care", + "care_source" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "What is the name of the individual listed as the signer for the VENDOR in the agreement? This is the name not associated with LA Care Health Plan and should be on the signature page of the contract. If no signature page exists, return 'N/A'." + }, + { + "field_name": "CHIEF_APPROVAL", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "What is the name of the individual listed as the signer for LA Care? This is the name associated with LA Care Health Plan on the signature page of the contract. If no signature page exists, return 'N/A'." + }, + { + "field_name": "VENDOR_ADMIN_CLINICAL_FUNCTIONS", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Is there any language in the agreement indicating whether the vendor shall be delegated any functions on behalf of LA Care? If so, return this language." + }, + { + "field_name": "INSURANCE_CLAUSE", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Is there any language detailing required or included insurance coverage for goods/services procured? If so, return the language explaining the extent of the coverage and its requirements; if there is no included or required coverage, return 'N/A'." + }, + { + "field_name": "MEMBERS_IMPACTED", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "If the contract mentions the number of members impacted by a procured service, whether it is in the format of a total population or a rate of members per unit, return it here. If this is not present, return 'N/A'." + }, + { + "field_name": "TITLE", + "vendors": [ + "la_care", + "sg_a", + "bcbs_arizona", + "care_source", + "generic" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "What is the complete title of the contract or the document? It is generally written in CAPITAL LETTERS and is generally found at the beginning of the document before a paragraph containing provider and payer name. It typically contains the words AGREEMENT, AMENDMENT, CONTRACT, ADDENDUM, MEMORANDUM, LETTER, PROVIDER or REQUEST. In case of agreement, typical structure is the provider name, followed by the phrase with agreement, followed potentially by a number signifying a version of the contract. In case of amendment, typical structure is word amendment followed by number, followed by the phrase with agreement. Extract the complete title of the contract or document as accurately as possible. Ensure: All leading characters are included for each word (e.g. 'NETWORK' instead of 'ETWORK'). Spaces between words are preserved in phrases (e.g., 'PROVIDER COLLABORATION' instead of 'PROVIDERCOLLABORATION'). If text appears incomplete or lacks spacing, use logical patterns like capitalization or common formatting rules to infer and correct the output. Replace new lines and line breaks with space." + }, + { + "field_name": "VENDOR_NAME", + "vendors": [ + "la_care", + "sg_a", + "bcbs_arizona", + "care_source", + "generic" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "What is the legal name of the vendor contracted for services in this agreement? This can be found in the preamble, header, or signature blocks \u2014 it is the non-payer party listed. Return the full legal company name as written. If not found, return N/A." + }, + { + "field_name": "CONFIDENTIAL_DATA_LANGUAGE", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Return the entire Confidentiality Clause from the contract or any language that addresses sharing confidential or sensitive data with the vendor. If no such language is present, return 'N/A'." + }, + { + "field_name": "TYPE_OF_CONFIDENTIAL_DATA", + "vendors": [ + "la_care", + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Identify and return types of sensitive/confidential data from the following list 'PHI, Member PII, Non Member PII,'. Return all the relevant types from the list which are being shared with the vendor. Return N/A if not found." + }, + { + "field_name": "PAYMENT_TYPE", + "vendors": [ + "la_care", + "sg_a", + "bcbs_arizona", + "generic" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Review all options and identify the most relevant payment structure for this contract from the following options:\n\n1. FIXED LUMP SUM:\nIf the contract states a single total amount for the entire term with no recurring rate (e.g., \u201ctotal contract value of $X,\u201d \u201cone-time fee of $X\u201d), return 'Fixed'.\n\n2. PAYMENT FREQUENCY:\nIf the contract specifies a recurring rate tied to a time period, return that period based on how the rate is expressed \u2014 not based on the invoicing or billing schedule:\n\nMonthly: rate described per month (e.g., \u201c$X per month,\u201d \u201cmonthly fee,\u201d \u201cmonthly rate,\u201d \u201ceach month\u201d)\n\nAnnual: rate described per year (e.g., \u201c$X per year,\u201d \u201cannual rate,\u201d \u201c$X annually,\u201d \u201c$X per annum,\u201d \u201cyearly rate\u201d)\n\n3. PAYMENT CAP:\nIf the contract sets a maximum limit or \u201cnot-to-exceed\u201d amount on total spending (e.g., \u201cshall not exceed $X,\u201d \u201cmaximum contract value of $X,\u201d \u201ccapped at $X,\u201d \u201cup to $X\u201d), return 'Payment Cap'.\nImportant: \n- Payment Cap, if present, takes priority over other payment structures.\n- Do not return Payment Terms (e.g., Net 30 days).\n- Do not return anything except: Monthly, Annual, Fixed, Payment Cap.\n- Return 'NOT_FOUND' only if no payment structure can be determined." + }, + { + "field_name": "PRICE_INCREASE_PROTECTION_CAP", + "vendors": [ + "la_care", + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Does the contract include Price Increase protection (such as a CPI Cap or Pricing Escalator)? If yes, return 'Y'; otherwise, return 'N'." + }, + { + "field_name": "OFFSHORE_ADDRESS", + "vendors": [ + "la_care", + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "If present, what is the offshore address, city or country where the services will be delivered." + }, + { + "field_name": "VENDOR_NOTICES_LOCATION", + "vendors": [ + "la_care", + "sg_a", + "care_source" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Where should official notices to the vendor be sent, as per this contract. If not found return address of the 'Vendor' OR 'Supplier' OR 'Licensor' if present else return N/A." + }, + { + "field_name": "TRAVEL_PERMITTED", + "vendors": [ + "la_care", + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Does the agreement indicate whether travel between the vendor and the client is permitted to be reimbursed? Assume that if 'on-site' work and lodging is mentioned that travel is allowed. If it explicitly forbids travel from being reimbursed, write 'No', and if it explicitly permits it write 'Yes'. If travel is permitted but requires pre-approval, write 'Pre-Approval Required.' If there is no mention of travel or on-site work, simply write 'Not Found'." + }, + { + "field_name": "PARENT_CONTRACT_TYPE", + "vendors": [ + "la_care", + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "If the contract has a parent agreement that it is amending, what is the type of that amended document? Examples of document types include but are not limited to Master Services Agreement, Statement of Work, etc. Only respond with the contract type, no other information." + }, + { + "field_name": "PAYER_NAME", + "vendors": [ + "la_care", + "sg_a", + "generic" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "What is the full legal name of the payer/health plan entity in this contract? This is typically found in the preamble or recitals. Return only the legal entity name. If not present, return 'N/A'." + }, + { + "field_name": "MEDICARE_PROVISIONS", + "vendors": [ + "la_care" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Does this contract contain any Medicare-specific provisions, clauses, or requirements? If yes, return the relevant clause or provision text verbatim. If no Medicare provisions are present, return 'N/A'." + }, + { + "field_name": "DELEGATED_SERVICE_AGREEMENT_TYPE", + "vendors": [ + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "List only the delegated services specified in this agreement. Return all the deliverable items related to delegated services. If no delegated services are mentioned, return 'N/A'." + }, + { + "field_name": "STATE", + "vendors": [ + "sg_a" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Return the State to which the 'Health Plan' in this contract applies to. If not found, Return the 'STATE' from the address of the 'Covered Entity' OR 'Company' OR 'Client' OR 'Customer' OR 'Buyer'. Do NOT return the state from the 'Governing Law' clause. Return N/A if not present." + }, + { + "field_name": "AMOUNT", + "vendors": [ + "la_care", + "sg_a", + "bcbs_arizona", + "care_source", + "generic" + ], + "relationship": "one_to_one", + "field_type": "full_context_single", + "depends_on": "PAYMENT_TYPE", + "prompt": "Extract the contract amount stated in the contract.\n\n1. EXPLICIT TOTAL: If the contract states an overall total fixed amount for the entire term (e.g., 'total contract value of $X', 'fixed fee of $X'), return that amount. If multiple fixed fees are listed with no stated overall total, add them together and return the sum.\n\n2. VARIABLE PRICING: If the pricing is entirely variable or utilization-based (per-hour, per-transaction, per-unit, per-member, percentage-based) with no guaranteed recurring payment obligation, return NOT_FOUND.\n - If the contract has BOTH a guaranteed periodic payment AND variable components, use the guaranteed periodic amount (step 3) and disregard the variable components for this field.\n\n3. PERIODIC RATE: If the contract specifies a recurring rate, return that rate as stated \u2014 do not multiply by term length or convert to a different period:\n - IMPORTANT: If pricing is shown in a table with a final 'Total' or 'Service Totals' row, use ONLY that total row (do NOT add individual line items \u2014 the total already includes them).\n - If multiple separate annual rates are listed for different services OR for different annual periods (e.g., 'First Annual Period: $X', 'Second Annual Period: $Y') with no single stated total, add them all together and return the combined total.\n - If multiple separate monthly rates are listed for different services with no single stated total, add them together and return the combined monthly amount.\n - Monthly rate (e.g., '$X per month', 'monthly fee', 'monthly rate', 'each month', 'monthly commitment', 'billed monthly') \u2192 return the monthly amount\n - Annual rate (e.g., '$X per year', 'annual rate', '$X annually', '$X per annum', 'yearly') \u2192 return the annual amount\n - Quarterly, weekly, or other periodic rate \u2192 return the stated rate for that period\n\n4. ONE-TIME FEES ONLY: If only one-time or setup fees exist with no recurring component (e.g., 'one-time fee', 'setup fee', 'implementation fee', 'equipment value'), return the total of those fees.\n\nRemove all formatting symbols ($, commas). Return ONLY the number.\nOutput: number only or NOT_FOUND." + }, + { + "field_name": "VENDOR_SERVICES", + "vendors": [ + "la_care", + "bcbs_arizona", + "sg_a" + ], + "relationship": "one_to_n", + "field_type": "bottom_up_page_level", + "prompt": null, + "breakout_template": "BOTTOM_UP_PRIMARY" + }, + { + "field_name": "VENDOR_SLAS_KPIS", + "vendors": [ + "la_care", + "bcbs_arizona", + "sg_a", + "generic" + ], + "relationship": "one_to_n", + "field_type": "bottom_up_page_level", + "prompt": null, + "breakout_template": "BOTTOM_UP_SLA_KPI" + }, + { + "field_name": "AUTO_RENEWAL_PERIOD", + "vendors": [ + "bcbs_arizona", + "care_source", + "generic" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "If the contract has automatic renewal provisions, what is the renewal period? Look for language specifying how long each automatic renewal term lasts (e.g., 'renews for successive 12-month periods', 'automatically extends for one year'). Return the value with its unit, such as '12 months', '1 year', '24 months', etc. If automatic renewal exists but the period is not specified, leave this field blank. If there is no automatic renewal, leave this field blank." + }, + { + "field_name": "TERM_TYPE", + "vendors": [ + "bcbs_arizona", + "care_source", + "generic" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "What type of term structure governs this contract's duration? Analyze the contract terms and classify as one of the following: 'Fixed' if the contract has a defined start and end date with no automatic renewal or no renewal without agreement; 'Perpetual' if the contract has no predefined expiration date and continues indefinitely; 'AutoRenew' if the contract automatically renews at the end of each term unless action is taken to cancel or terminate. Return only one of these three values: Fixed, Perpetual, or AutoRenew. If the term structure is unclear or not specified, return 'Fixed' as the default." + }, + { + "field_name": "TERM_CLAUSE", + "vendors": [ + "bcbs_arizona", + "care_source", + "generic" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Extract the section that contains the contract's key dates and term information. Look for sections labeled 'Terms and Conditions', 'Term', 'Term and Termination', 'Duration', or similar that contain effective dates, start dates, end dates, or expiration dates. PRIORITIZE sections with actual dates (in formats like MM/DD/YYYY, YYYY-MM-DD, or written dates) over sections that only discuss renewal policies or termination procedures without specific dates. IMPORTANT: Begin the returned text with the full section heading exactly as it appears in the document (including its section number if present, e.g. '6. Term and Termination.' or 'SECTION 3. TERM & TERMINATION'). Return the full, verbatim text of the section including the heading." + }, + { + "field_name": "DESCRIPTION_OF_SERVICES", + "vendors": [ + "la_care", + "sg_a", + "bcbs_arizona", + "care_source", + "generic" + ], + "relationship": "one_to_one", + "field_type": "full_context_single", + "prompt": "Identify and summarize the explicitly listed services or deliverables in this contract.\n\nONLY include services that are:\n1. Formally listed under a section heading such as \"Services\", \"Description of Services\", \"Scope of Services\", \"Scope of Work\", \"Statement of Work\", \"Project Services\", \"Services and Deliverables\", or similar.\n2. Listed as rows in a responsibility matrix, service table, or deliverables table.\n3. Clearly labeled as a numbered or bulleted service item (e.g., \"5.1. Contractor will provide the following Services...\").\n\nDO NOT include:\n- Implementation details, sub-tasks, or technical descriptions of HOW a service is performed (e.g., data mappings, API configurations, system integrations).\n- Requirements, assumptions, or conditions attached to a service.\n- General contract language or obligations that are not a defined service or deliverable.\n\nWrite a concise summary paragraph describing the services covered by this contract. The summary should capture the overall scope and nature of the services without listing every detail. If multiple services exist, group related services together in your description.\n\nReturn ONLY the summary paragraph with no preamble, introduction, or commentary. Do not begin with phrases like \"Based on the contract text\" or \"The services include\" \u2014 start directly with the substance of the summary.\n\nIf no formally listed services or deliverables are found, return NOT_FOUND." + }, + { + "field_name": "Fixed_or_Annual_Spend_Amount", + "vendors": [ + "care_source" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Determine if the contract's total commitment is 'Fixed' or 'Annual':\n\nStep 1: Locate the total contract amount in the contract.\nStep 2: Look at the exact text describing that amount. Does it contain any of these words (case-insensitive):\n- 'yearly'\n- 'annually'\n- 'per year'\n- 'per annum'\n- 'annual'\n\nStep 3: Apply decision:\n- If YES (keyword found in amount text) \u2192 return 'Annual'\n- If NO (no keyword in amount text) \u2192 return 'Fixed' if amount covers full term/multiple years, otherwise 'NOT_FOUND'\n\nImportant: Do a simple keyword search in the amount description. If any of the five keywords appear, return 'Annual'.\n\nOutput: Fixed | Annual | NOT_FOUND." + }, + { + "field_name": "Commodity", + "vendors": [ + "care_source" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "valid_values": "COMMODITY_CODES", + "prompt": "Based on the Description of Services, select the MOST appropriate commodity category from this list: {valid_values}. You MUST choose one category from the list - every service will fit at least one category. Output: exact commodity category name from list." + }, + { + "field_name": "MaxAutoRenewalsAllowed", + "vendors": [ + "care_source" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "prompt": "Extract the maximum number of renewal YEARS allowed. Only applicable if ExpirationTermType is AutoRenew. Calculate: (number of renewals) \u00d7 (length of each period). If unlimited or not specified, return UNLIMITED. Output: number only (e.g., 5) or UNLIMITED or NOT_APPLICABLE." + }, + { + "field_name": "cus_ContractType", + "vendors": [ + "care_source" + ], + "relationship": "one_to_one", + "field_type": "full_context", + "valid_values": "CUSTOMER_CONTRACT_TYPE", + "prompt": "Based on the contract title and content, classify as ONE of the following contract types: {valid_values}.\n\nImportant classification rules:\n- If the document is a Quote, Sales Order, or Purchase Order that outlines pricing and services/deliverables, classify it as 'Statement of Work (SOW)'\n- If the document modifies terms of an existing agreement without adding new services/pricing, classify it as 'Amendment'\n- A document titled 'Change Order' should be classified as 'Statement of Work (SOW)' if it primarily adds new services/pricing, or 'Amendment' if it only modifies existing terms\n\nChoose the most appropriate type from this list. Output: the classification or NOT_FOUND." + }, + { + "field_name": "Termination_Clause_Section_Num", + "vendors": [ + "care_source" + ], + "relationship": "one_to_one", + "field_type": "value_derived", + "depends_on": "TERM_CLAUSE", + "prompt": "Using ONLY the TERM_CLAUSE text provided above (do not search the full contract), extract the section number that precedes the Term and Termination clause heading.\n\nExamples:\n- '6. Term and Termination.' -> return '6'\n- 'SECTION 6. TERM & TERMINATION' -> return '6'\n- '3.2 Term' -> return '3.2'\n- 'Article 12 - Termination Provisions' -> return '12'\n- 'IV. Termination Clause' -> return 'IV'\n- 'Termination:' -> return N/A\n- 'This Agreement shall continue...' -> return N/A\n\nReturn ONLY the number or Roman numeral, with no other text (no 'Section', 'Article', parentheses, or periods). If no section number is present at the beginning, return N/A." + } +] \ No newline at end of file