Files
doczyai-pipelines/src/config.py
T
Praneel Panchigar f1df468587 Merged in bugfix/exhibit-smart-chunking-cost-improvements (pull request #985)
Bugfix/exhibit smart chunking cost improvements

* Add opt-in instrumentation for per-call token and row-count tracing

Introduce src/utils/instrumentation.py (thread-safe CSV logger) and
src/utils/instrumentation_context.py (ContextVar scope plus
submit_with_context / map_with_context helpers for propagating context
into ThreadPoolExecutor workers).

Emit events at every Bedrock call in llm_utils.invoke_claude, including
in-memory claude_cache hits, with full input/output/cache-read/cache-write
token breakdown. Emit row-count events at each row-mutating stage in the
one-to-N pipeline (clean_reimbursement_primary,
filter_services_without_reimbursements, methodology_breakout,
split_service_terms, carveout, dynamic_code_assignment,
lesser_of_distribution, dynamic_assignment) and chunking / retrieval
events in exhibit smart chunking (chunking_done, retrieval_done) plus
exhibit lifecycle events (exhibit_start, exhibit_gate_skip,
stage_transition).

All hooks are no-ops unless DOCZY_INSTRUMENTATION=1; production defaults
unchanged. Runner and inspector scripts in …
* Extend instrumentation: segment, cache_miss_reason, retry/error events, runtime hookup, analyzer

Schema expansion and new event types:
- Add segment column to every llm_call / llm_call_inmem_hit, resolved from
  USAGE_LABEL_TO_SEGMENT (authoritative) with scope fallback. Mapping built
  from grep + smoke-run ground truth; earlier guessed entries removed.
- Add cache_miss_reason classifier: hit / first_call / ttl_expired /
  under_min_tokens / silent_miss / not_attempted. Uses a per-process
  _cache_key_seen dict guarded by its own lock.
- Emit llm_retry on each retry attempt (attempt, error_class, error_msg,
  backoff_sec) and llm_error on exhausted retries in ec2_claude_3_and_up
  (rotation and non-rotation branches) plus local_claude_3_and_up.
- Add six new CSV columns: segment, cache_miss_reason, attempt,
  error_class, error_msg, backoff_sec. Total schema now 34 columns.

Defensive kwarg hygiene:
- _ctx_minus_explicit_keys filter on all emit sites to prevent
  segment / filename kwarg collisions between …
* Merged dev into bugfix/exhibit-smart-chunking-cost-improvements

* Merged dev into bugfix/exhibit-smart-chunking-cost-improvements

* Make instrumentation tracking on by default

Flip the gate: instrumentation is now enabled unless DOCZY_INSTRUMENTATION
is explicitly set to a falsy value (0/false/no/off). Replaces the prior
opt-in behaviour where it was off unless DOCZY_INSTRUMENTATION=1 was set.

* Fix missing WRITE_TO_S3 patch in upload_instrumentation_csv test

* Merged dev into bugfix/exhibit-smart-chunking-cost-improvements


Approved-by: Katon Minhas
2026-04-28 15:58:21 +00:00

492 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import csv
import os
import re
import sys
from collections import defaultdict
from datetime import datetime
import boto3
from botocore.config import Config
from dotenv import load_dotenv
# Please see the following confluence page for instructions on loading keys into your
# `.env`:
# https://aarete.atlassian.net/wiki/spaces/DoczyAI/pages/1283751954/Using+.env+for+AWS+keys
load_dotenv()
######################################## UTILS ###################################################
# def check_s3_bucket_exists(s3_client, bucket_name: str):
# try:
# s3_client.head_bucket(Bucket=bucket_name)
# print(f"{bucket_name} exists")
# except s3_client.exceptions.ClientError as e:
# error_code = e.response["Error"]["Code"]
# if error_code == "404":
# print(f"{bucket_name} does not exist")
# raise
# else:
# print(f"Error {error_code}")
# raise
# except Exception as e:
# raise
# return
from botocore.exceptions import NoCredentialsError
def check_s3_bucket_exists(s3_client, bucket_name: str):
try:
s3_client.head_bucket(Bucket=bucket_name)
print(f"{bucket_name} exists")
except NoCredentialsError:
print("AWS credentials not found")
return False
except s3_client.exceptions.ClientError as e:
error_code = e.response["Error"]["Code"]
if error_code == "404":
print(f"{bucket_name} does not exist")
raise
else:
print(f"Error {error_code}")
raise
except Exception as e:
raise
return True
######################################## GENERAL SETTINGS ########################################
TEST = False # True to run test prompt - just for testing model connection
TODAY = datetime.now().strftime("%Y%m%d")
MAX_CONTEXT_LENGTH = 100000
######################################## TRACKING ########################################
UPLOAD_FREQUENCY = 10
######################################## SYSTEM ARGUMENTS ########################################
# read_mode, run_mode, input_dir, output_dir, consolidated_output_dir
def get_value_from_env(key: str, default_value: str = None) -> str:
value = os.getenv(key, default_value)
if value is not None:
return value
raise KeyError(f"{key} not found in .env file")
def get_arg_value(arg_name, default):
args = [item for item in sys.argv if arg_name == item.split("=")[0]]
if len(args) == 0:
return default
elif len(args) == 1:
return args[0].split("=")[1]
else:
raise Exception(f"Too many argument values: {args}")
# Run config args
RUN_MODE = get_arg_value("run_mode", "ec2") # Valid: local or ec2
READ_MODE = get_arg_value("read_mode", "s3") # Valid: local or s3
BATCH_ID = get_arg_value("batch_id", None) # BATCH_ID is mandatory
# Logging settings
LOG_LEVEL = get_arg_value(
"log_level", "INFO"
).upper() # Standard logging level for console output
# Per-file logging settings (for debugging)
ENABLE_PER_FILE_LOGGING = get_arg_value("enable_per_file_logging", "False") == "True"
PER_FILE_LOG_LEVEL = get_arg_value(
"per_file_log_level", "DEBUG"
).upper() # DEBUG, INFO, WARNING, ERROR
PER_FILE_LOG_DIR = get_arg_value("per_file_log_dir", "logs")
ENABLE_RUNTIME_ROTATION = False # Set to True to enable multi-runtime failover
# Field args
FIELDS = get_arg_value("fields", "all") # Valid: one_to_one, one_to_n, all
FIELD_JSON_PATH = "src/prompts/investment_prompts.json"
# Specific field or field group to extract (comma-separated)
# Valid: "all", "claim_type", or specific field names like "CLAIM_TYPE_CD,CONTRACT_TITLE"
# "claim_type" is a shortcut for CLAIM_TYPE_CD + AARETE_DERIVED_CLAIM_TYPE_CD + CONTRACT_TITLE
SPECIFIC_FIELDS = get_arg_value("specific_fields", "all")
# Field groups - predefined sets of related fields
FIELD_GROUPS = {
"claim_type": [
"CLAIM_TYPE_CD",
"AARETE_DERIVED_CLAIM_TYPE_CD",
"CONTRACT_TITLE",
],
"dates": [
"EFFECTIVE_DT",
"AARETE_DERIVED_EFFECTIVE_DT",
"TERMINATION_DT",
"AARETE_DERIVED_TERMINATION_DT",
],
"provider": [
"PROV_GROUP_TIN",
"PROV_GROUP_NPI",
"PROV_GROUP_NAME_FULL",
"PROV_OTHER_TIN",
"PROV_OTHER_NPI",
"PROV_OTHER_NAME_FULL",
],
}
def get_specific_fields_list():
"""Get the list of specific fields to extract based on config."""
if SPECIFIC_FIELDS == "all":
return None # None means extract all fields
# Check if it's a field group
if SPECIFIC_FIELDS in FIELD_GROUPS:
return FIELD_GROUPS[SPECIFIC_FIELDS]
# Otherwise, treat as comma-separated list of field names
return [f.strip() for f in SPECIFIC_FIELDS.split(",") if f.strip()]
# Client-specific prompts
CLOVER_PROMPTS_PATH = "src/prompts/clover_prompts.json"
BCBS_PROMISE_PROMPTS_PATH = "src/prompts/bcbs_promise_prompts.json"
CHC_PROMPTS_PATH = "src/prompts/chc_prompts.json"
# Maps client name → path to its extra one-to-one prompt JSON.
# Used by run_one_to_one_prompts to load client-specific fields alongside SaaS fields.
CLIENT_PROMPTS_MAP = {
"clover": CLOVER_PROMPTS_PATH,
"bcbs_promise": BCBS_PROMISE_PROMPTS_PATH,
"chc": CHC_PROMPTS_PATH,
}
RAG_RETRIEVAL_QUESTIONS_PATH = "src/prompts/rag_retrieval_questions.json"
# Client-specific args
STATE = [s.strip() for s in get_arg_value("state", "None").upper().split("-") if s]
CLIENT = [s.strip() for s in get_arg_value("client", "None").split("-") if s]
# File IO args
LOCAL_PATH = get_arg_value(
"input_dir", "data/test"
) # Valid: any valid path that contains txt files
S3_PREFIX = get_arg_value("s3_prefix", "")
S3_BUCKET = get_arg_value("s3_bucket", "")
OUTPUT_DIRECTORY = get_arg_value(
"output_dir", "output_individual"
) # Valid: any valid directory
TRACKING_FILE = get_arg_value("tracking_file", f"{BATCH_ID}_tracking.csv")
WRITE_TO_S3 = get_arg_value("write_to_s3", "True") == "True"
S3_OUTPUT_BUCKET = get_arg_value("s3_output_bucket", default="doczy-output")
# Filtering args
FILTER_ALREADY_PROCESSED = (
get_arg_value("filter", False) == "True"
) # Valid: True or False
# Multithreading args
MAX_WORKERS = int(get_arg_value("max_workers", 2)) # Valid: any integer
# default false for the regex based IRS chunking
IRS_REGEX_CHECK = get_arg_value("irs_regex_check", False) == "True"
# QCQA args - only need to be set when running qa_qc.py INDEPENDENTLY, not needed for at-scale runs
AC_DF = get_arg_value("ac_df", None)
B_DF = get_arg_value("b_df", None)
DF_READ_MODE = get_arg_value("df_read_mode", "s3")
######################################## FILE LOG ########################################
FILE_LOG_FIELDS = [
"filename",
"process_type",
"start_time",
"end_time",
"processing_time_seconds",
"delta_time_hours",
"status",
"error",
"memory_usage_mb",
"cpu_percent",
]
FILE_LOG_NAME = f"{BATCH_ID}_filelog.csv"
if not os.path.exists(FILE_LOG_NAME):
with open(FILE_LOG_NAME, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FILE_LOG_FIELDS)
writer.writeheader()
######################################## CLIENT SETTINGS ########################################
CLIENT_NAME = get_arg_value("client_name", "Test-Client")
PROV_TYPE = None
######################################## I/O SETTINGS ########################################
OUTPUT_BASE = "outputs"
# Determine pipeline-type-aware output directory:
# saas → outputs/saas
# clients → outputs/clients/<name> (e.g. clover)
# vendors → outputs/vendors/<name> (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}"
# Pipeline-specific output directories
SAAS_OUTPUT_DIRECTORY = f"{OUTPUT_BASE}/saas"
QC_QA_OUTPUT_DIRECTORY = f"{OUTPUT_BASE}/qc_qa"
PARENT_CHILD_OUTPUT_DIRECTORY = f"{OUTPUT_BASE}/parent_child"
INDIVIDUAL_OUTPUT_DIRECTORY = f"{OUTPUT_BASE}/individual"
REPORTING_OUTPUT_DIRECTORY = f"{OUTPUT_BASE}/reports"
UNPROCESSED_RESULTS_NAME = f"{BATCH_ID}-B-Unprocessed.csv"
AC_RESULTS_NAME = f"{BATCH_ID}-AC.csv"
B_RESULTS_NAME = f"{BATCH_ID}-B.csv"
TABLE_ANALYSIS_NAME = f"{BATCH_ID}-Table-Analysis.csv"
######################################## AWS SETTINGS #########################################
# RUN_MODE = "local" # for testing purposes only
# Bedrock
if RUN_MODE == "local":
AWS_ACCESS_KEY_ID = get_value_from_env("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = get_value_from_env("AWS_SECRET_ACCESS_KEY")
AWS_SESSION_TOKEN = get_value_from_env("AWS_SESSION_TOKEN")
# Bedrock
config = Config(read_timeout=2000, max_pool_connections=60)
BEDROCK_RUNTIME = boto3.client(
service_name="bedrock-runtime",
region_name="us-east-1",
aws_access_key_id=AWS_ACCESS_KEY_ID,
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
aws_session_token=AWS_SESSION_TOKEN,
config=config,
)
# S3
S3_CLIENT = boto3.client(
"s3",
region_name="us-east-2",
aws_access_key_id=AWS_ACCESS_KEY_ID,
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
aws_session_token=AWS_SESSION_TOKEN,
)
elif RUN_MODE == "ec2":
# Bedrock
config = Config(
read_timeout=4000, max_pool_connections=60
) # Doubled read_timeout to 4000
BEDROCK_RUNTIME = boto3.client(
service_name="bedrock-runtime", region_name="us-east-1", config=config
)
# S3
S3_CLIENT = boto3.client("s3", region_name="us-east-2")
# Check that S3_OUTPUT_BUCKET exists and raise an error if not
if WRITE_TO_S3:
check_s3_bucket_exists(S3_CLIENT, S3_OUTPUT_BUCKET)
# Claude model IDs
# Note: Claude 2 support has been removed - only Claude 3+ models are supported
MODEL_ID_CLAUDE35_SONNET = "anthropic.claude-3-5-sonnet-20240620-v1:0"
MODEL_ID_CLAUDE35_SONNET_V2 = "anthropic.claude-3-5-sonnet-20241022-v2:0"
MODEL_ID_CLAUDE37_SONNET = "us.anthropic.claude-3-7-sonnet-20250219-v1:0"
MODEL_ID_CLAUDE4_SONNET = "us.anthropic.claude-sonnet-4-20250514-v1:0"
MODEL_ID_CLAUDE45_SONNET = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
MODEL_ID_CLAUDE45_HAIKU = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
# Model aliases for easy upgrades
MODEL_ALIASES = {
"sonnet_latest": MODEL_ID_CLAUDE45_SONNET,
"haiku_latest": MODEL_ID_CLAUDE45_HAIKU,
"legacy_sonnet": MODEL_ID_CLAUDE35_SONNET,
}
def resolve_model_id(model_identifier: str) -> str:
"""
Resolve a model identifier to its actual model ID.
Supports both direct model IDs and aliases.
"""
return MODEL_ALIASES.get(model_identifier, model_identifier)
######################################## CLAUDE AND PROMPT TRACKING ########################################
ENABLE_ANSWER_TRACKING = False
ANSWER_TRACKING_DICT = {}
######################################## POSTPROCESSING SETTINGS ########################################
FUZZY_MATCH_THRESHOLD = 0.8
FILE_NAME_COLUMN = "Contract Name"
BLANKS_THRESHOLD = 0.5
######################################## COMPLEX FLAG SETTINGS ########################################
COMPLEX_MAX_WORKERS = 50
RATE_THRESHOLD = 10
TABLE_THRESHOLD = 2
COMPLEX_OUTPUT_PATH = "complex_flag_output"
COMPLEX_OUTPUT_FILENAME = f"{TODAY}_complex_flag_test.csv"
TABLE_ROW_LIMIT = 10
######################################## TABLE SPLITTING SETTINGS ########################################
# Enable table splitting feature
TABLE_SPLITTING_ENABLED = get_arg_value("table_splitting_enabled", "True") == "True"
# Maximum cells (rows × columns) per table sub-page
MAX_TABLE_CELLS_PER_SUBPAGE = int(get_arg_value("max_table_cells_per_subpage", "40"))
# Minimum rows to trigger splitting
MIN_TABLE_ROWS_TO_SPLIT = int(get_arg_value("min_table_rows_to_split", "20"))
# Table splitting method (currently only "cells" is supported)
TABLE_SPLITTING_METHOD = get_arg_value("table_splitting_method", "cells")
######################################## OUTPUT FILE SPLITTING SETTINGS ########################################
# Maximum rows per split file (files are split to keep filenames together)
MAX_ROWS_PER_SPLIT = int(get_arg_value("max_rows_per_split", "70000"))
######################################## EC2 ########################################
def assume_role_and_get_runtime(account_id, role_name):
sts_client = boto3.client("sts")
role_arn = f"arn:aws:iam::{account_id}:role/{role_name}"
assumed_role = sts_client.assume_role(
RoleArn=role_arn, RoleSessionName=f"UATLambdaSession"
)
credentials = assumed_role["Credentials"]
runtime = boto3.client(
service_name="bedrock-runtime",
region_name="us-east-1",
aws_access_key_id=credentials["AccessKeyId"],
aws_secret_access_key=credentials["SecretAccessKey"],
aws_session_token=credentials["SessionToken"],
config=config,
)
return runtime
dev_account_id = "660131068782"
dev_role_name = "doczy-dev-bedrock-lambda-role"
prod_account_id = "211125720533"
prod_role_name = "doczy-prod-bedrock-lambda-role"
############## S3 BUCKETS FOR PDF FILES ##############
DOCZY_PDF_FILES_BUCKET_NAME = "doczy-pdf-files" # sample bucket name added. we can either use this or create a new one
DOCZY_PDF_FILES_BUCKET_S3_URL = f"https://{DOCZY_PDF_FILES_BUCKET_NAME}.s3.us-east-2.amazonaws.com/" # sample s3 url added. we can either use this or create a new one
############## VISION API SETTINGS ##############
ENABLE_VISION = get_arg_value("enable_vision", "False") == "True" # False by default
############## PARENT CHILD CONFIG ##############
PERFORM_PARENT_CHILD_MAPPING = get_arg_value("perform_pc", "True") == "True"
DOCZY_OUTPUT_FOR_PC = get_arg_value(
"doczy_output_for_pc", ""
) # Pass either S3 URI path, or local path
############## DOCUMENT TYPE CLASSIFICATION SETTINGS ##############
PERFORM_DTC = (
get_arg_value("perform_dtc", "False") == "True"
) # Enable document type classification pre-filter
DTC_PROMPTS_JSON_PATH = "src/prompts/document_classification_prompts.json"
DTC_MAX_PAGES_TO_CHECK = (
5 # Number of pages to check per document for contract detection
)
DTC_OUTPUT_FILE = (
f"{BATCH_ID}_Document_Classification_Report.csv" # Output CSV file path
)
DTC_JSON_OUTPUT_FOLDER = "dtc_json_results" # Folder for individual JSON results
############## PRE-DOCZY REPORT SETTINGS ##############
PERFORM_PRE_DOCZY = (
get_arg_value("perform_pre_doczy", "False") == "True"
) # Enable Pre-Doczy classification + keyword report
PDF_S3_BUCKET = get_arg_value("pdf_s3_bucket", "") # S3 bucket containing original PDFs
PDF_S3_PREFIX = get_arg_value("pdf_s3_prefix", "") # S3 prefix for original PDF files
MASTER_FILE_LIST = get_arg_value(
"master_file_list", ""
) # Path to master Excel file for cross-referencing (e.g. Molina_files_Report.xlsx)
RUN_BASE_PREFIX = get_arg_value(
"run_base_prefix", ""
) # Base S3 prefix for the run folder (e.g., utah-contracts/04162026-run/)
# When set, auto-derives all subfolder paths from this base prefix
# Auto-derive S3_PREFIX, PDF_S3_PREFIX, and PDF_S3_BUCKET from RUN_BASE_PREFIX
if RUN_BASE_PREFIX:
_run_base = RUN_BASE_PREFIX.rstrip("/") + "/"
if not S3_PREFIX:
S3_PREFIX = f"{_run_base}txt-files/"
if not PDF_S3_PREFIX:
PDF_S3_PREFIX = f"{_run_base}pdf-files/"
if not PDF_S3_BUCKET:
PDF_S3_BUCKET = S3_BUCKET
############## POST-DOCZY VALIDATION REPORT SETTINGS ##############
PERFORM_POST_DOCZY = (
get_arg_value("perform_post_doczy", "False") == "True"
) # Enable post-Doczy validation report
PERFORM_POST_DOCZY_EXCEL = (
get_arg_value("perform_post_doczy_excel", "False") == "True"
) # Enable Excel-only post-Doczy report (no raw text files needed)
POST_DOCZY_JSON_OUTPUT_FOLDER = (
"post_doczy_json_results" # Folder for individual JSON results
)
DOCZY_RESULTS_PATH = get_arg_value(
"doczy_results_path", ""
) # Path to Doczy extraction output CSV/Excel (local path or S3 URI)
############## DASHBOARD POSTPROCESSING ##############
# Run dashboard postprocessing (comma-separated list format). Default: CC only.
# Set to True via run_dashboard arg when dashboard output is needed.
RUN_DASHBOARD_POSTPROCESSING = get_arg_value("run_dashboard", "False") == "True"
############## QC/QA VALIDATION SETTINGS ##############
# Enable QC/QA validation in pipeline
ENABLE_QC_QA = get_arg_value("enable_qc_qa", "True") == "True"
############## ADD_AARETE_DERIVED_PAYER_NAME SETTINGS ############
# STATE_FLAG controls whether payer names are treated as state-specific.
#
# True → Preserve state distinctions.
# Example: "Molina Healthcare of Texas" and
# "Molina Healthcare of Utah" remain separate.
#
# False → Collapse regional state suffixes into national brand.
# Example: "Molina Healthcare of Texas" and
# "Molina Healthcare of Utah" both become "Molina Healthcare".
# Leading state-based brand names (e.g., "Oklahoma Complete Health")
# are preserved.
STATE_FLAG = True
############## INSTRUMENTATION SETTINGS ##############
# On by default; opt out with DOCZY_INSTRUMENTATION=0 (or false/no/off).
INSTRUMENTATION_ENABLED = os.environ.get("DOCZY_INSTRUMENTATION", "").lower() not in (
"0",
"false",
"no",
"off",
)
INSTRUMENTATION_CSV_PATH = os.environ.get("DOCZY_INSTRUMENTATION_CSV", "")