Files
doczyai-pipelines/src/pipelines/shared/preprocessing/hybrid_smart_chunking_funcs.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

496 lines
18 KiB
Python

"""
RAG-Based Field Extraction Functions
This module provides RAG (Retrieval-Augmented Generation) based field extraction
for contract processing using hybrid retrieval (BM25 + semantic search) with reranking.
"""
import concurrent.futures
import gc
import logging
import re
from typing import List, Tuple, Dict
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from src.utils import (
instrumentation_context,
llm_utils,
string_utils,
timing_utils,
rag_utils,
)
import src.pipelines.shared.preprocessing.preprocessing_funcs as preprocessing_funcs
import src.pipelines.shared.preprocessing.hybrid_smart_chunking_preprocessing as hybrid_smart_chunking_preprocessing
from src import config
from src.prompts import prompt_templates
from src.prompts.fieldset import FieldSet
from src.pipelines.shared.extraction.one_to_one_funcs import (
check_and_update_effective_date,
)
from src.utils.rag_utils import (
RAGConfig,
HSC_CONFIG,
get_reranker_client,
get_embeddings,
create_vectorstore,
create_hybrid_retriever,
)
def clip_context_by_tokens(context: str, max_tokens: int = 150000) -> str:
"""
Clip context text if it exceeds max_tokens.
Uses closed formula: char_count / 3.5 for Claude tokenization.
Args:
context: Text to potentially clip
max_tokens: Maximum token limit (default 150k for Claude 200k window)
Returns:
Clipped or original context text
"""
if not context:
return ""
# Estimate tokens: char_count / 3.5 (accurate for Claude)
token_count = int(len(context) / 3.5)
# Only clip if exceeds max_tokens
if token_count > max_tokens:
approx_chars = int(max_tokens * 3.5)
clipped_context = context[:approx_chars]
logging.debug(f"Context clipped from ~{token_count} to ~{max_tokens} tokens")
return clipped_context
else:
logging.debug(f"Context size ~{token_count} tokens - no clipping needed")
return context
def prepare_documents_for_chunking(
sections: dict, filename: str = "contract"
) -> List[Document]:
"""Prepare contract sections as documents for chunking."""
documents = []
for section, section_data in sections.items():
section_header = section
page_numbers = section_data["pages"]
section_text = section_data["content"]
doc = Document(
page_content=section_text,
metadata={
"section_header": section_header,
"section_length": len(section_text),
"pages": page_numbers,
"filename": filename,
},
)
documents.append(doc)
return documents
def format_chunks_for_llm(text_dict: Dict, docs: List[Document]) -> str:
"""Format retrieved documents for LLM context."""
if not docs:
return ""
pages = set()
for doc in docs:
if "pages" in doc.metadata:
pages.update(doc.metadata["pages"])
pages = set(map(str, pages))
# Filter out None values and pages that don't exist in text_dict
context = "\n\n".join(
[
text_dict[page]
for page in pages
if page in text_dict and text_dict[page] is not None
]
)
return clip_context_by_tokens(context)
def add_page_numbers_to_chunks(sections):
prev = None
updated_sections = {}
for section, section_data in sections.items():
matches = re.findall(r"Start of Page No\.?\s*=\s*(\d+)", section_data)
prev_last_page = int(prev[-1]) if prev else None
matches = [int(m) for m in matches]
all_matches = (
matches + [prev_last_page] if prev_last_page is not None else matches
)
all_matches = sorted(set(all_matches))
updated_sections[section] = {"content": section_data, "pages": all_matches}
prev = all_matches
return updated_sections
def preprocessing_pipeline(contract_text):
cleaned_text = preprocessing_funcs.clean_newlines(contract_text)
cleaned_text = preprocessing_funcs.clean_law_symbols(cleaned_text)
cleaned_text = re.sub(
r"^(.*?)(Start of Page No\.?\s*=\s*1)", r"\2", cleaned_text, flags=re.DOTALL
)
sections, original_content, line_ending, verification_result = (
hybrid_smart_chunking_preprocessing.split_contract_text_into_sections(
cleaned_text
)
)
is_preserved = verification_result[0] # get status
if not is_preserved:
logging.debug(
"Failed to preserve content during section split, reverting to full contract"
)
sections = {}
chunk_size = 9000 # keep bigger chunks to have enough coverage as it would be split further by text splitter
for i in range(0, len(cleaned_text), chunk_size):
section_name = f"Section {i // chunk_size}"
sections[section_name] = cleaned_text[i : i + chunk_size]
sections_with_page_numbers = add_page_numbers_to_chunks(sections)
return sections_with_page_numbers, is_preserved
def update_provider_name(
answers_dict: dict,
filename: str,
one_to_one_fields: FieldSet,
retriever,
reranker,
rag_config: RAGConfig,
text_dict: dict,
constants,
) -> dict:
"""Update provider name fields using enhanced RAG if initial extraction failed.
Args:
answers_dict: Dictionary containing extracted field values
filename: Name of the file being processed
one_to_one_fields: FieldSet containing field definitions
retriever: Hybrid retriever instance
reranker: Reranker client
rag_config: RAG configuration
text_dict: Dictionary mapping page numbers to text content
constants: Constants object
Returns:
Updated answers_dict with provider name fields populated
"""
provider_name = answers_dict.get("PROVIDER_NAME")
# if (provider name is a str and non empty) or (provider name is a list and has only one non empty item),
# then populate AARETE_DERIVED_PROVIDER_NAME with that provider name
if provider_name and (
(isinstance(provider_name, str) and not string_utils.is_empty(provider_name))
or (
isinstance(provider_name, list)
and len(provider_name) == 1
and not string_utils.is_empty(provider_name)
)
):
answers_dict["AARETE_DERIVED_PROVIDER_NAME"] = (
provider_name if isinstance(provider_name, str) else provider_name[0]
)
logging.info(
f"AARETE_DERIVED_PROVIDER_NAME set to: {answers_dict['AARETE_DERIVED_PROVIDER_NAME']} | Filename: {filename}"
)
else:
logging.info(
f"Attempting enhanced RAG extraction for AARETE_DERIVED_PROVIDER_NAME | Filename: {filename}"
)
# Create custom RAG config with more aggressive retrieval for AARETE_DERIVED_PROVIDER_NAME
enhanced_config = RAGConfig(
CHUNK_SIZE=rag_config.CHUNK_SIZE,
CHUNK_OVERLAP=rag_config.CHUNK_OVERLAP,
SEMANTIC_TOP_K=35,
KEYWORD_TOP_K=25,
ENSEMBLE_TOP_K=40,
RERANKER_TOP_K=30, # increase reranker top k to have more candidates for LLM to extract from
BM25_WEIGHT=0.5, # Balanced for better keyword matching
VECTOR_WEIGHT=0.5, # Balanced for better semantic matching
RERANKER=rag_config.RERANKER,
EMBEDDING_MODEL_ID=rag_config.EMBEDDING_MODEL_ID,
BEDROCK_REGION=rag_config.BEDROCK_REGION,
)
hsc_provider_name = prompt_hsc_single_field(
one_to_one_fields.get_field("AARETE_DERIVED_PROVIDER_NAME"),
retriever,
reranker,
enhanced_config, # Use enhanced config instead of default
text_dict,
constants,
filename,
)[
1
] # Get the field value from the returned tuple
if not string_utils.is_empty(hsc_provider_name):
answers_dict["AARETE_DERIVED_PROVIDER_NAME"] = (
hsc_provider_name[0]
if isinstance(hsc_provider_name, list)
else hsc_provider_name
)
logging.info(
f"AARETE_DERIVED_PROVIDER_NAME extracted via enhanced RAG: {answers_dict['AARETE_DERIVED_PROVIDER_NAME']} | Filename: {filename}"
)
if string_utils.is_empty(answers_dict["PROVIDER_NAME"]):
answers_dict["PROVIDER_NAME"] = answers_dict.get(
"AARETE_DERIVED_PROVIDER_NAME", "N/A"
)
logging.info(
f"Empty PROVIDER_NAME updated to: {answers_dict['PROVIDER_NAME']} | Filename: {filename}"
)
return answers_dict
def run_hybrid_smart_chunked_fields(
one_to_one_fields: FieldSet,
constants,
contract_text: str,
filename: str,
text_dict: dict,
rag_config: RAGConfig = HSC_CONFIG,
) -> dict[str, str]:
"""Process fields using RAG: hybrid retrieval (BM25+semantic) + reranking.
Uses retrieval questions from the field's retrieval_question attribute for chunk retrieval,
then actual LLM prompts from the field's prompt attribute for field extraction.
"""
logging.debug(f"Starting RAG extraction for {filename}")
# Collect fields that have retrieval questions defined
# Only process fields that have the retrieval_question attribute set
fields_to_process = [
field
for field in one_to_one_fields.fields
if field.field_type == "smart_chunked"
and not string_utils.is_empty(field.retrieval_question)
]
if not fields_to_process:
logging.debug(
f"No fields with retrieval questions found | Filename: {filename}"
)
return {}
try:
# Setup: clients, splitter, chunks
with timing_utils.timed_block("hsc.setup", context=filename):
reranker = get_reranker_client(rag_config.RERANKER)
embeddings = get_embeddings(
rag_config.EMBEDDING_MODEL_ID, rag_config.BEDROCK_REGION
)
splitter = RecursiveCharacterTextSplitter(
chunk_size=rag_config.CHUNK_SIZE,
chunk_overlap=rag_config.CHUNK_OVERLAP,
add_start_index=True,
strip_whitespace=True,
separators=["\n\n", "\n", " ", ""],
)
with timing_utils.timed_block("hsc.preprocessing", context=filename):
sections, is_preserved = preprocessing_pipeline(contract_text)
docs = prepare_documents_for_chunking(sections, filename)
chunks = splitter.split_documents(docs)
del docs
gc.collect()
if not chunks:
return {}
# Build retriever
with timing_utils.timed_block("hsc.vectorstore_creation", context=filename):
vectorstore = create_vectorstore(chunks, embeddings)
retriever = create_hybrid_retriever(chunks, vectorstore, rag_config)
answers_dict = {}
with timing_utils.timed_block(
"hsc.parallel_field_processing", context=filename
):
with concurrent.futures.ThreadPoolExecutor(
max_workers=min(len(fields_to_process), 20)
) as executor:
futures = [
instrumentation_context.submit_with_context(
executor,
prompt_hsc_single_field,
field,
retriever,
reranker,
rag_config,
text_dict,
constants,
filename,
)
for field in fields_to_process
]
for future in concurrent.futures.as_completed(futures):
try:
field_name, field_value, field = future.result()
answers_dict[field_name] = field_value
except Exception as e:
logging.error(f"Error processing field: {str(e)}")
# Update provider name fields with enhanced RAG if needed
answers_dict = update_provider_name(
answers_dict=answers_dict,
filename=filename,
one_to_one_fields=one_to_one_fields,
retriever=retriever,
reranker=reranker,
rag_config=rag_config,
text_dict=text_dict,
constants=constants,
)
del retriever, vectorstore, chunks
gc.collect()
logging.debug(f"RAG extraction done: {len(answers_dict)} fields")
# FILENAME_AMENDMENT_NUM
answers_dict = extract_amendment_num_from_filename(answers_dict, filename)
# Normalize PROVIDER_STATE
answers_dict = string_utils.normalize_state_field(
answers_dict, field_name="PROVIDER_STATE"
)
# Normalize PAYER_STATE
answers_dict = string_utils.normalize_state_field(
answers_dict, field_name="PAYER_STATE"
)
return answers_dict
except Exception as e:
logging.error(f"RAG pipeline failed: {e}")
return {}
def extract_amendment_num_from_filename(answer_dict: dict, filename: str) -> dict:
"""Extract amendment number from filename if not found in contract content.
Args:
answer_dict (dict): Dictionary containing CONTRACT_AMENDMENT_NUM key.
filename (str): Filename to extract amendment number from.
Returns:
dict: Updated answer_dict with FILENAME_AMENDMENT_NUM populated.
"""
if (
string_utils.is_empty(answer_dict.get("CONTRACT_AMENDMENT_NUM"))
or answer_dict.get("CONTRACT_AMENDMENT_NUM") == "UNKNOWN"
):
prompt, _parser = prompt_templates.EXTRACT_AMENDMENT_NUM_FROM_FILENAME(filename)
llm_answer_raw = llm_utils.invoke_claude(
prompt,
"sonnet_latest",
filename,
cache=True,
instruction=prompt_templates.EXTRACT_AMENDMENT_NUM_FROM_FILENAME_INSTRUCTION(),
usage_label="EXTRACT_AMENDMENT_NUM_FROM_FILENAME",
)
try:
llm_answer_final = _parser(llm_answer_raw)
answer_dict["FILENAME_AMENDMENT_NUM"] = llm_answer_final.get(
"amendment_number", "N/A"
)
except (ValueError, KeyError) as e:
logging.error(
f"Failed to parse amendment number from filename {filename}: {e}"
)
answer_dict["FILENAME_AMENDMENT_NUM"] = "N/A"
return answer_dict
def prompt_hsc_single_field(
field, retriever, reranker, rag_config, text_dict, constants, filename
):
"""Process a single field in parallel for HSC."""
try:
retrieval_query = field.retrieval_question
if not retrieval_query:
return (field.field_name, "N/A", field)
# Retrieve + rerank using retrieval question
with timing_utils.timed_block(
f"hsc.field.{field.field_name}.retrieval", context=filename
):
retrieved = retriever.invoke(retrieval_query)[: rag_config.ENSEMBLE_TOP_K]
reranked = rag_utils.rerank_documents(
retrieval_query,
retrieved,
reranker,
rag_config.RERANKER_TOP_K,
rag_config.RERANKER,
)
# Build context and use actual LLM prompt for extraction
context = format_chunks_for_llm(text_dict, reranked)
if len(context) == 0:
logging.warning(
f"No context retrieved for {field.field_name}, marking as Null value"
)
return (field.field_name, "N/A", field)
llm_prompt = field.get_prompt_dict(
constants
) # Returns dict of {field_name : prompt}
if field.field_name in ["CONTRACT_TITLE", "PAYER_NAME"]:
# concatenate values from first 3 items in text_dict to provide more context for contract title extraction, as it is often found in the beginning of the contract
context = "\n\n".join(list(text_dict.values())[:3])
if field.field_name == "EFFECTIVE_DT":
# for effective date always provide first 3 pages as context as effective date is often found in the beginning of the contract and it is important to provide enough context for LLM to extract it correctly
context = "\n\n".join(list(text_dict.values())[:3]) + "\n\n" + context
prompt, _parser = prompt_templates.ONE_TO_ONE_SINGLE_FIELD_TEMPLATE(
context, llm_prompt, field_name=field.field_name
)
logging.debug(f"Field: {field.field_name}")
logging.debug(f"Retrieval question: {retrieval_query}")
logging.debug(f"LLM prompt: {llm_prompt}")
logging.debug(
f"context with len {len(context)}: {context[:500]}........{context[-500:]}"
) # log only the beginning and end of the context to avoid clutter
# LLM call
with timing_utils.timed_block(
f"hsc.field.{field.field_name}.llm_call", context=filename
):
llm_answer_raw = llm_utils.invoke_claude(
prompt, "sonnet_latest", filename, max_tokens=8192
)
logging.debug(f"Raw LLM answer for {field.field_name}: {llm_answer_raw}")
try:
llm_answer_final = _parser(llm_answer_raw) # returns dict
field_value = llm_answer_final.get(field.field_name)
return (field.field_name, field_value, field)
except Exception as e:
logging.warning(
f"Failed to parse JSON response for field {field.field_name}: "
f"{type(e).__name__}: {str(e)}. Setting field value to N/A."
)
return (field.field_name, "N/A", field)
except Exception as e:
logging.error(f"Error on {field.field_name}: {type(e).__name__}: {str(e)}")
return (field.field_name, "N/A", field)