Merged in feature/one-to-one-confidence-scoring (pull request #1002)

Feature/one to one confidence scoring

* T1 plumbing: capture per-field confidence + retrieved-chunk metadata for 1:1 HSC fields

Prep work for the 1:1 confidence-scoring stage. No scoring logic yet — this
just collects the inputs the next ticket (rule-based scorer) will consume.

- ONE_TO_ONE_SINGLE_FIELD_TEMPLATE: ask the LLM for confidence (0.0-1.0),
  verdict (correct/uncertain/not_found), and supporting_snippet alongside
  the field value. Existing field parser passes the extra keys through
  unchanged.
- prompt_hsc_single_field: now returns a 4-tuple (name, value, field,
  metadata) where metadata holds the confidence/verdict/snippet plus a
  lightweight summary of which chunks the LLM saw (count + ids).
  _extract_hsc_metadata is defensive: clamps out-of-range confidences,
  defaults a missing/garbage verdict, caps the snippet at 500 chars,
  returns _empty_hsc_metadata() on every bail-out path.
- run_hybrid_smart_chunked_fields: optional return_metadata flag. Default
  return shape unchanged (dict of values) for backwards compatibility;
  wh…
* DAIP2-2692: field-type-aware rule scorer for 1:1 confidence

Replaces the T1 placeholder _CONF (just the LLM-stated confidence) with
a rule-based score per field. No new LLM calls -- pure local computation.

New module: src/qc_qa/confidence/
  - field_types.py  : FieldType enum + per-field mapping + weight presets
                      (date / money / tin / npi / code / name / boolean /
                      free_text). New 1:1 fields default to FREE_TEXT.
  - signals.py      : exact / token_overlap / fuzzy / number_match / regex /
                      grounding / na_check. Each signal returns float in
                      [0,1] or None when not applicable.
  - scorer.py       : score_field(field_name, value, metadata, contract_text,
                      field_prompt) -> float. Runs the signals that have
                      non-zero weight for the field's type, renormalizes
                      over the signals that actually fired, blends lightly
                      with the LLM-stated confidence (15%…
* DAIP2-2694: integrate calibration table into 1:1 confidence scoring

Adds a per-field-type calibration step that maps raw confidence scores
to empirically-calibrated values via a linearly-interpolated curve.
Identity by default, so the stage is a no-op until the curve is
backfilled from QC data; once backfilled, a "0.X confidence" output
reflects roughly X% empirical accuracy.

New files:
  - src/qc_qa/confidence/calibration.py
      * loads + caches the curve from JSON
      * linear interpolation between anchor points
      * defensive: bad inputs / missing file / malformed table all fall
        back to identity (no scoring regression on failure)
  - src/qc_qa/confidence/calibration_table.json
      * identity baseline shipped as the starting point
      * one curve per FieldType plus a 'default' fallback
      * documents the regeneration entry point for the QC-data backfill
.gitignore:
  * carve-out !src/qc_qa/confidence/*.json so the table actually ships

Scorer wiring:
  * apply calibration AFTER the r…
* DAIP2-2695: field-level confidence distribution stats + flagged-row report

At end of every run, emit two CSVs under tracking/:
  - <BATCH_ID>-CONFIDENCE-SUMMARY.csv : per-field distribution stats
      columns: field, n, mean, p10, p50, p90, n_below_threshold,
               threshold, pct_below_threshold
      sorted by pct_below_threshold descending so the worst fields are
      at the top of the file.
  - <BATCH_ID>-CONFIDENCE-FLAGGED.csv : the reviewer hit list
      columns: FILE_NAME, field, confidence, value, threshold
      sorted by confidence ascending.

Both are derived from the per-row <FIELD>_CONF columns the rule scorer
populates. Empty inputs (no _CONF columns yet, or no rows) skip the
write rather than emitting empty files.

Threshold:
  * config.CONFIDENCE_THRESHOLD (CLI arg confidence_threshold=, default 0.6)

New module:
  - src/qc_qa/confidence/summary.py
      compute_summary(final_df, threshold) -> per-field stats DataFrame
      compute_flagged(final_df, threshold) -> below-threshold r…
* Skip _CONF columns in standard_postprocess value-shape normalizers

The per-column loop in standard_postprocess substring-matches column
names ("_IND" in col, "TIN" in col, "_DT" in col, etc.) to decide which
shape normalizer to apply. After DAIP2-2692 introduced <FIELD>_CONF
confidence-score columns, those columns started colliding with the
match conditions:

  - AUTO_RENEWAL_IND_CONF matched "_IND in col" -> normalize_indicator_field
    coerced the 1.0 float to the string "N", destroying the score.
  - *_DT_CONF / *_TIN_CONF etc. were also at risk via the same pattern.

Fix: short-circuit the loop with `if col.endswith("_CONF"): continue`
so confidence floats are never passed through the value-shape
normalizers, regardless of what string happens to appear in the
underlying field name.

Verified on the 1:1 smoke test (conf-1to1-smoke2): AUTO_RENEWAL_IND_CONF
now lands as 1.0 in RESULTS-FULL.csv and CONFIDENCE-SUMMARY.csv shows
all 10 HSC fields with proper float distributions.

* Merge remote-tracking branch 'origin/dev' into feature/one-to-one-confidence-scoring

* Black formatting: split generator in reorder_columns _CONF carve-out

Black wanted the multi-line generator expression formatted with one
clause per line. Behaviour unchanged.

* Fix na_check so amendment docs differentiate from base agreements

Three small bugs were stacking up to make CONTRACT_AMENDMENT_NUM flag every
file that returned a missing value, regardless of whether the document
actually was an amendment.

1) is_na() did not treat pandas/float NaN as N/A. When the LLM returned
   no value, the cell came through as a float NaN, str()'d to "nan", and
   the N/A token set ({"", "n/a", "na", "none", "null", "not found"})
   did not match. na_check therefore never fired and the other signals
   ran on the literal string "nan", producing a flat low score driven
   only by the LLM's self-reported confidence. Now also catches float
   NaN via value != value.

2) signal_na_check used three coarse buckets (0.30/0.50/0.85) and a
   per-keyword saturation of 2 mentions. Both produced the same bucket
   for docs that mention a concept 2x in boilerplate and docs whose
   entire topic is that concept. Switched to a continuous score with
   a saturation floor of ~30 mentions, so 8 mentions…
* Merged dev into feature/one-to-one-confidence-scoring


Approved-by: Katon Minhas
This commit is contained in:
Faizan Mohiuddin
2026-05-12 21:53:05 +00:00
committed by Katon Minhas
parent 830d228fb3
commit 339cfc74fc
16 changed files with 1275 additions and 20 deletions
+1
View File
@@ -45,6 +45,7 @@ Thumbs.db
!src/constants/**/*.csv
!src/constants/**/*.json
!src/prompts/*.json
!src/qc_qa/confidence/*.json
!requirements.txt
# Build artifacts
+5
View File
@@ -211,6 +211,11 @@ 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")
# Confidence scoring (1:1) reporting threshold. Per-field <FIELD>_CONF values
# below this land in CONFIDENCE-FLAGGED.csv and count toward the
# pct_below_threshold column in CONFIDENCE-SUMMARY.csv.
CONFIDENCE_THRESHOLD = float(get_arg_value("confidence_threshold", "0.6"))
######################################## FILE LOG ########################################
+44
View File
@@ -452,6 +452,20 @@ def main(client: str = "saas", testing=False, test_params={}):
config.BATCH_ID, run_timestamp
)
# ========== Confidence reporting (DAIP2-2695) ==========
# Build per-field distribution + flagged-for-review DataFrames from
# any <FIELD>_CONF columns that landed in FINAL_RESULT_DF_CC. Both
# frames may be empty when no scoring took place (e.g. no 1:1 fields
# in the field set); in that case we skip writing.
from src.qc_qa.confidence.summary import compute_summary, compute_flagged
confidence_summary_df = compute_summary(
FINAL_RESULT_DF_CC, threshold=config.CONFIDENCE_THRESHOLD
)
confidence_flagged_df = compute_flagged(
FINAL_RESULT_DF_CC, threshold=config.CONFIDENCE_THRESHOLD
)
with timing_utils.timed_block("write_outputs", log_level="INFO"):
if config.WRITE_TO_S3:
doczy_output_for_pc = io_utils.write_s3(
@@ -480,6 +494,21 @@ def main(client: str = "saas", testing=False, test_params={}):
if qc_qa_stats_df is not None:
io_utils.write_s3(qc_qa_stats_df, "", run_timestamp, "qc_qa_stats")
if not confidence_summary_df.empty:
io_utils.write_s3(
confidence_summary_df,
"",
run_timestamp,
"confidence_summary",
)
if not confidence_flagged_df.empty:
io_utils.write_s3(
confidence_flagged_df,
"",
run_timestamp,
"confidence_flagged",
)
if not per_file_usage_df.empty and not batch_summary_df.empty:
logging.info("Exporting usage and cost tracking data to S3...")
io_utils.write_s3(per_file_usage_df, "", run_timestamp, "usage")
@@ -518,6 +547,21 @@ def main(client: str = "saas", testing=False, test_params={}):
qc_qa_stats_df, "", run_timestamp, "qc_qa_stats"
)
if not confidence_summary_df.empty:
io_utils.write_local(
confidence_summary_df,
"",
run_timestamp,
"confidence_summary",
)
if not confidence_flagged_df.empty:
io_utils.write_local(
confidence_flagged_df,
"",
run_timestamp,
"confidence_flagged",
)
if config.PERFORM_PARENT_CHILD_MAPPING:
if FINAL_RESULT_DF_CC.empty:
logging.warning(
+38 -5
View File
@@ -278,15 +278,22 @@ def run_one_to_one_prompts(
################## RUN HYBRID SMART CHUNKED PROMPTS ##################
# RAG function loads retrieval questions internally and matches with investment_prompts.json
# Only run if there are fields to extract
hybrid_smart_chunked_answers_dict = {}
hybrid_smart_chunked_answers_dict: dict = {}
hybrid_smart_chunked_metadata_dict: dict = {}
if one_to_one_fields.contains_fields():
with timing_utils.timed_block(
"one_to_one.hybrid_smart_chunking", context=filename
):
hybrid_smart_chunked_answers_dict = (
hybrid_smart_chunking_funcs.run_hybrid_smart_chunked_fields(
one_to_one_fields, constants, contract_text, filename, text_dict
)
(
hybrid_smart_chunked_answers_dict,
hybrid_smart_chunked_metadata_dict,
) = hybrid_smart_chunking_funcs.run_hybrid_smart_chunked_fields(
one_to_one_fields,
constants,
contract_text,
filename,
text_dict,
return_metadata=True,
)
logging.debug(
f"Hybrid Smart Chunked Answers for {filename}: {hybrid_smart_chunked_answers_dict}"
@@ -300,6 +307,32 @@ def run_one_to_one_prompts(
# Add HSC's N/A if field doesn't exist yet
one_to_one_results[key] = value
################## SCORE PER-FIELD CONFIDENCE ##################
# T2 (DAIP2-2692): rule-based confidence per HSC field. Combines
# exact / token-overlap / fuzzy / number-match / regex / grounding /
# na-check signals with field-type-aware weights. T3 will layer an
# LLM verifier on top for gray-zone scores.
from src.qc_qa.confidence import score_field
for field_name, metadata in hybrid_smart_chunked_metadata_dict.items():
meta = metadata if isinstance(metadata, dict) else {}
# Pull the field's prompt for the na_check signal's keyword set.
field_obj = (
one_to_one_fields.get_field(field_name)
if one_to_one_fields.contains_fields()
else None
)
field_prompt = getattr(field_obj, "prompt", "") if field_obj else ""
rule_score = score_field(
field_name=field_name,
value=one_to_one_results.get(field_name),
metadata=meta,
contract_text=contract_text,
field_prompt=field_prompt,
)
one_to_one_results[f"{field_name}_CONF"] = rule_score
################## RUN CLIENT FULL-CONTEXT FIELDS ##################
# Client-specific full_context fields (e.g. Clover's 12 audit/filing/CA fields)
if client_full_context_fields is not None:
@@ -90,6 +90,12 @@ def standard_postprocess(df, constants: Constants):
)
for col in df.columns:
# _CONF columns hold per-field confidence floats (DAIP2-2692). They
# must not be touched by any of the value-shape normalizers below,
# which substring-match column names and would otherwise corrupt
# e.g. AUTO_RENEWAL_IND_CONF (matches "_IND") into a Y/N string.
if col.endswith("_CONF"):
continue
if "_IND" in col:
df[col] = df[col].apply(postprocessing_funcs.normalize_indicator_field)
# Normalize _IND fields and clean up TIN/NPI fields
@@ -483,7 +483,11 @@ def reorder_columns(
Steps:
1. Adds any missing columns from `column_order` to the DataFrame at once, filled with empty strings.
2. Reorders the columns of the DataFrame to match `column_order`.
3. Drops any columns in the DataFrame that are not in `column_order`.
3. Drops any columns in the DataFrame that are not in `column_order`,
except columns ending with `_CONF` which are preserved at the end.
This carve-out lets the one-to-one confidence-scoring stage emit
per-field <FIELD>_CONF columns without registering each one in
FIELD_FORMAT_MAPPING.
Args:
df (pd.DataFrame): The input DataFrame.
@@ -510,8 +514,15 @@ def reorder_columns(
[df_copy, pd.DataFrame(empty_cols, index=df_copy.index)], axis=1
)
# Keep only columns in column_order; drop any extras not in the mapping
# Keep only columns in column_order; drop any extras not in the mapping,
# except *_CONF columns which are preserved (sorted) at the end.
final_column_order = [col for col in column_order if col in df_copy.columns]
conf_columns = sorted(
col
for col in df_copy.columns
if col.endswith("_CONF") and col not in column_order
)
final_column_order.extend(conf_columns)
# Return the DataFrame with reordered columns
return df_copy[final_column_order]
@@ -269,11 +269,18 @@ def run_hybrid_smart_chunked_fields(
filename: str,
text_dict: dict,
rag_config: RAGConfig = HSC_CONFIG,
) -> dict[str, str]:
return_metadata: bool = False,
):
"""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.
By default returns a dict of {field_name: value} for backwards compatibility.
When return_metadata=True, returns a tuple (answers_dict, metadata_dict) where
metadata_dict maps each field_name to {confidence, verdict, supporting_snippet,
retrieved_chunk_count, retrieved_chunk_ids}. Used by the one-to-one confidence
scoring stage downstream.
"""
logging.debug(f"Starting RAG extraction for {filename}")
@@ -291,7 +298,7 @@ def run_hybrid_smart_chunked_fields(
logging.debug(
f"No fields with retrieval questions found | Filename: {filename}"
)
return {}
return ({}, {}) if return_metadata else {}
try:
# Setup: clients, splitter, chunks
@@ -316,7 +323,7 @@ def run_hybrid_smart_chunked_fields(
gc.collect()
if not chunks:
return {}
return ({}, {}) if return_metadata else {}
# Build retriever
with timing_utils.timed_block("hsc.vectorstore_creation", context=filename):
@@ -324,6 +331,7 @@ def run_hybrid_smart_chunked_fields(
retriever = create_hybrid_retriever(chunks, vectorstore, rag_config)
answers_dict = {}
metadata_dict: dict[str, dict] = {}
with timing_utils.timed_block(
"hsc.parallel_field_processing", context=filename
):
@@ -347,8 +355,9 @@ def run_hybrid_smart_chunked_fields(
for future in concurrent.futures.as_completed(futures):
try:
field_name, field_value, field = future.result()
field_name, field_value, field, metadata = future.result()
answers_dict[field_name] = field_value
metadata_dict[field_name] = metadata
except Exception as e:
logging.error(f"Error processing field: {str(e)}")
@@ -381,11 +390,11 @@ def run_hybrid_smart_chunked_fields(
answers_dict, field_name="PAYER_STATE"
)
return answers_dict
return (answers_dict, metadata_dict) if return_metadata else answers_dict
except Exception as e:
logging.error(f"RAG pipeline failed: {e}")
return {}
return ({}, {}) if return_metadata else {}
def extract_amendment_num_from_filename(answer_dict: dict, filename: str) -> dict:
@@ -427,15 +436,96 @@ def extract_amendment_num_from_filename(answer_dict: dict, filename: str) -> dic
return answer_dict
# Cap on the joined retrieved-chunks blob we keep in metadata. The grounding
# signal scans this text for the extracted value; 12k chars is enough to cover
# the typical ~10 reranked chunks without ballooning memory or making the
# downstream search slow.
_RETRIEVED_CHUNKS_TEXT_CAP = 12_000
def _empty_hsc_metadata() -> dict:
"""Default metadata payload for HSC fields that bail out before extracting."""
return {
"confidence": None,
"verdict": "not_found",
"supporting_snippet": "",
"retrieved_chunk_count": 0,
"retrieved_chunk_ids": [],
"retrieved_chunks_text": "",
}
def _extract_hsc_metadata(parsed: dict, reranked) -> dict:
"""Pull confidence / verdict / supporting_snippet out of the parsed LLM dict
and attach a summary of the retrieved chunks the LLM saw.
The chunks summary includes both lightweight ids (for traceability) and
the joined chunk text (for the grounding signal in the rule-based scorer
downstream). Text is capped so the metadata stays bounded.
Stays defensive: any of the keys may be missing or the wrong type if the LLM
didn't follow the prompt exactly. We coerce/default rather than fail.
"""
raw_conf = parsed.get("confidence") if isinstance(parsed, dict) else None
try:
conf = float(raw_conf) if raw_conf is not None else None
if conf is not None:
conf = max(0.0, min(1.0, conf))
except (TypeError, ValueError):
conf = None
verdict = parsed.get("verdict") if isinstance(parsed, dict) else None
if verdict not in {"correct", "uncertain", "not_found"}:
verdict = "uncertain" if conf is not None else "not_found"
snippet = parsed.get("supporting_snippet") if isinstance(parsed, dict) else None
snippet = str(snippet) if snippet is not None else ""
chunk_ids: list[str] = []
chunk_texts: list[str] = []
if reranked:
for doc in reranked:
metadata = getattr(doc, "metadata", {}) or {}
chunk_id = (
metadata.get("chunk_id")
or metadata.get("id")
or metadata.get("source")
or ""
)
if chunk_id:
chunk_ids.append(str(chunk_id))
text = getattr(doc, "page_content", "") or ""
if text:
chunk_texts.append(str(text))
joined_text = "\n\n".join(chunk_texts)
if len(joined_text) > _RETRIEVED_CHUNKS_TEXT_CAP:
joined_text = joined_text[:_RETRIEVED_CHUNKS_TEXT_CAP]
return {
"confidence": conf,
"verdict": verdict,
"supporting_snippet": snippet[:500], # cap so it doesn't bloat the CSV
"retrieved_chunk_count": len(reranked) if reranked else 0,
"retrieved_chunk_ids": chunk_ids,
"retrieved_chunks_text": joined_text,
}
def prompt_hsc_single_field(
field, retriever, reranker, rag_config, text_dict, constants, filename
):
"""Process a single field in parallel for HSC."""
"""Process a single field in parallel for HSC.
Returns a 4-tuple: (field_name, field_value, field, metadata) where
metadata is the dict produced by _extract_hsc_metadata() — used by the
one-to-one confidence scoring stage downstream.
"""
try:
retrieval_query = field.retrieval_question
if not retrieval_query:
return (field.field_name, "N/A", field)
return (field.field_name, "N/A", field, _empty_hsc_metadata())
# Retrieve + rerank using retrieval question
with timing_utils.timed_block(
@@ -457,7 +547,7 @@ def prompt_hsc_single_field(
logging.warning(
f"No context retrieved for {field.field_name}, marking as Null value"
)
return (field.field_name, "N/A", field)
return (field.field_name, "N/A", field, _empty_hsc_metadata())
llm_prompt = field.get_prompt_dict(
constants
@@ -501,15 +591,16 @@ def prompt_hsc_single_field(
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)
metadata = _extract_hsc_metadata(llm_answer_final, reranked)
return (field.field_name, field_value, field, metadata)
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)
return (field.field_name, "N/A", field, _empty_hsc_metadata())
except Exception as e:
logging.error(f"Error on {field.field_name}: {type(e).__name__}: {str(e)}")
return (field.field_name, "N/A", field)
return (field.field_name, "N/A", field, _empty_hsc_metadata())
+7 -1
View File
@@ -2610,7 +2610,13 @@ Answer the following question: {field_prompt}
{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".
Briefly explain your answer, then put the final answer in a properly-formatted JSON dictionary with these keys:
- The field name (key) mapped to the extracted value (or "N/A" if not found).
- "confidence": a number between 0.0 and 1.0 expressing how confident you are in the extracted value (1.0 = certain, 0.0 = guess).
- "verdict": one of "correct" / "uncertain" / "not_found".
- "supporting_snippet": the shortest exact text from the contract that supports your answer (max ~200 chars), or "" if the answer is "N/A".
If there is no valid answer, return "N/A" as the field value, set "verdict" to "not_found", and "supporting_snippet" to "".
"""
# Use field-aware parser if field_name is provided
+16
View File
@@ -0,0 +1,16 @@
"""One-to-one field confidence scoring.
Stages (per the implementation plan):
1. signals - rule-based per-field signals (this ticket: DAIP2-2692)
2. llm_verifier - gray-zone LLM check (DAIP2-2693)
3. calibration - LLM-stated -> empirical accuracy mapping (DAIP2-2694)
4. report - field-level distribution stats (DAIP2-2695)
This module is intentionally self-contained. It consumes the metadata dict
produced by `prompt_hsc_single_field` (see hybrid_smart_chunking_funcs.py)
and emits a per-field rule score in [0, 1].
"""
from src.qc_qa.confidence.scorer import score_field
__all__ = ["score_field"]
+161
View File
@@ -0,0 +1,161 @@
"""Confidence calibration: map raw scores -> empirically-calibrated scores.
LLM-stated and even rule-based confidence scores tend to be miscalibrated:
a "0.9" rule score does not always correspond to 90% empirical accuracy.
This module applies a per-field-type calibration curve so a final score
of 0.X reflects roughly an X% probability of being correct in production.
The calibration curve is a small JSON table of (raw, calibrated) anchor
points per field type. Lookup is linear interpolation. Out-of-domain
inputs (NaN, negative, > 1.0) are clamped before lookup.
Defaults ship as identity (raw == calibrated), so this stage is a no-op
until a real curve is backfilled from QC data. That keeps the pipeline
safe to ship before we have enough labeled samples to calibrate against.
To regenerate the table when QC data is available, run:
uv run python -m src.qc_qa.confidence.generate_calibration_table
(That script will land alongside this module when the data pipeline is
ready; until then the identity table is correct behavior.)
"""
from __future__ import annotations
import bisect
import json
import logging
import os
from typing import Dict, List, Optional, Tuple
from src.qc_qa.confidence.field_types import FieldType
logger = logging.getLogger(__name__)
# Anchor point: (raw_score, calibrated_score)
_AnchorPoint = Tuple[float, float]
_TABLE_FILENAME = "calibration_table.json"
_TABLE_PATH = os.path.join(os.path.dirname(__file__), _TABLE_FILENAME)
# Module-level cache of (field_type_key -> sorted list of (raw, calibrated))
_CACHED_TABLE: Optional[Dict[str, List[_AnchorPoint]]] = None
# Identity fallback used if the file is missing or unreadable. Never raises;
# a broken file should not break extraction.
_IDENTITY: List[_AnchorPoint] = [(0.0, 0.0), (1.0, 1.0)]
def _parse_table(raw: dict) -> Dict[str, List[_AnchorPoint]]:
"""Validate + normalize the JSON shape into sorted anchor lists.
Anything malformed (wrong types, fewer than two points, points outside
[0, 1]) silently falls back to identity for that key. Calibration must
never make scoring worse than no calibration.
"""
parsed: Dict[str, List[_AnchorPoint]] = {}
for key, points in raw.items():
if key.startswith("_"):
continue # metadata fields like _comment, _schema_version
if not isinstance(points, list) or len(points) < 2:
parsed[key] = list(_IDENTITY)
continue
cleaned: List[_AnchorPoint] = []
for pt in points:
if not isinstance(pt, (list, tuple)) or len(pt) != 2:
continue
try:
raw_v = float(pt[0])
cal_v = float(pt[1])
except (TypeError, ValueError):
continue
if 0.0 <= raw_v <= 1.0 and 0.0 <= cal_v <= 1.0:
cleaned.append((raw_v, cal_v))
if len(cleaned) < 2:
parsed[key] = list(_IDENTITY)
continue
cleaned.sort(key=lambda p: p[0])
# Ensure the curve covers the full [0, 1] domain. If the lowest
# anchor is > 0 or the highest is < 1, extend with identity ends so
# the interpolation never extrapolates.
if cleaned[0][0] > 0.0:
cleaned.insert(0, (0.0, cleaned[0][1]))
if cleaned[-1][0] < 1.0:
cleaned.append((1.0, cleaned[-1][1]))
parsed[key] = cleaned
return parsed
def _load_table() -> Dict[str, List[_AnchorPoint]]:
"""Load the calibration table from disk. Caches the result; safe to
call repeatedly. Falls back to identity-for-default on any failure.
"""
global _CACHED_TABLE
if _CACHED_TABLE is not None:
return _CACHED_TABLE
try:
with open(_TABLE_PATH, "r", encoding="utf-8") as f:
raw = json.load(f)
_CACHED_TABLE = _parse_table(raw)
if "default" not in _CACHED_TABLE:
_CACHED_TABLE["default"] = list(_IDENTITY)
except FileNotFoundError:
logger.info(
"Calibration table not found at %s; using identity. "
"This is expected before QC backfill.",
_TABLE_PATH,
)
_CACHED_TABLE = {"default": list(_IDENTITY)}
except Exception as exc:
logger.warning(
"Failed to load calibration table from %s (%s); using identity.",
_TABLE_PATH,
exc,
)
_CACHED_TABLE = {"default": list(_IDENTITY)}
return _CACHED_TABLE
def reload_table() -> None:
"""Clear the cached table so the next calibrate() call re-reads the JSON.
Intended for tests and offline regeneration workflows.
"""
global _CACHED_TABLE
_CACHED_TABLE = None
def _interpolate(points: List[_AnchorPoint], x: float) -> float:
"""Linear interpolation between sorted (raw, calibrated) anchors."""
raws = [p[0] for p in points]
idx = bisect.bisect_left(raws, x)
if idx == 0:
return points[0][1]
if idx >= len(points):
return points[-1][1]
x0, y0 = points[idx - 1]
x1, y1 = points[idx]
if x1 == x0:
return y0
return y0 + (y1 - y0) * (x - x0) / (x1 - x0)
def calibrate(field_type: FieldType | str, raw_score: float) -> float:
"""Map a raw confidence in [0, 1] to a calibrated confidence in [0, 1].
Always returns a finite float in [0, 1]. Bad inputs return the raw
value clamped (i.e. the calibration is a no-op on the failure path).
"""
# Coerce + clamp input.
try:
x = float(raw_score)
except (TypeError, ValueError):
return 0.0
if x != x: # NaN
return 0.0
x = max(0.0, min(1.0, x))
table = _load_table()
key = field_type.value if isinstance(field_type, FieldType) else str(field_type)
points = table.get(key) or table.get("default") or _IDENTITY
out = _interpolate(points, x)
return max(0.0, min(1.0, out))
@@ -0,0 +1,15 @@
{
"_comment": "Per-field-type calibration curves: (raw_score, calibrated_score) anchor points. Linearly interpolated between adjacent points; values outside [0,1] are clamped. The defaults ship as identity (raw == calibrated) so the pipeline is a no-op until a real curve is backfilled from QC data. Regenerate with src/qc_qa/confidence/generate_calibration_table.py once enough labeled samples are available.",
"_schema_version": 1,
"_generated_at": null,
"_generated_from": null,
"default": [[0.0, 0.0], [1.0, 1.0]],
"date": [[0.0, 0.0], [1.0, 1.0]],
"money": [[0.0, 0.0], [1.0, 1.0]],
"tin": [[0.0, 0.0], [1.0, 1.0]],
"npi": [[0.0, 0.0], [1.0, 1.0]],
"code": [[0.0, 0.0], [1.0, 1.0]],
"name": [[0.0, 0.0], [1.0, 1.0]],
"boolean": [[0.0, 0.0], [1.0, 1.0]],
"free_text":[[0.0, 0.0], [1.0, 1.0]]
}
+145
View File
@@ -0,0 +1,145 @@
"""Per-field-type registry and signal weight presets.
Each one-to-one HSC field maps to a FieldType. Each FieldType has a preset
of signal weights tuned for that kind of value:
- DATE -> regex + number_match dominate
- MONEY -> number_match dominates
- TIN/NPI -> exact + regex dominate
- CODE -> exact dominates
- NAME -> fuzzy + token_overlap dominate
- BOOLEAN -> exact + na_check dominate
- FREE_TEXT -> token_overlap + fuzzy + grounding (default)
Signals not listed for a type get weight 0 and are skipped at scoring time.
The scorer renormalizes over the signals it actually computed, so a missing
signal (e.g. no retrieved chunks for grounding) does not bias the result.
"""
from __future__ import annotations
from enum import Enum
from typing import Dict
class FieldType(str, Enum):
DATE = "date"
MONEY = "money"
TIN = "tin"
NPI = "npi"
CODE = "code"
NAME = "name"
BOOLEAN = "boolean"
FREE_TEXT = "free_text"
# Map field name -> FieldType. Defaults to FREE_TEXT for anything not listed.
# Keep this list narrow on purpose; new fields added here should have an
# obvious, single-type interpretation. When in doubt, leave it as FREE_TEXT
# and let the default weights apply.
FIELD_TYPE_MAP: Dict[str, FieldType] = {
# Dates
"EFFECTIVE_DT": FieldType.DATE,
"TERMINATION_DT": FieldType.DATE,
"AARETE_DERIVED_EFFECTIVE_DT": FieldType.DATE,
"AARETE_DERIVED_TERMINATION_DT": FieldType.DATE,
"REIMB_EFFECTIVE_DT": FieldType.DATE,
"REIMB_TERMINATION_DT": FieldType.DATE,
# Names
"PAYER_NAME": FieldType.NAME,
"AARETE_DERIVED_PAYER_NAME": FieldType.NAME,
"PROVIDER_NAME": FieldType.NAME,
"AARETE_DERIVED_PROVIDER_NAME": FieldType.NAME,
"CONTRACT_TITLE": FieldType.NAME,
# Boolean / indicator
"AUTO_RENEWAL_IND": FieldType.BOOLEAN,
# Free text (default category, listed for clarity)
"AUTO_RENEWAL_TERM": FieldType.FREE_TEXT,
"PROVIDER_STATE": FieldType.FREE_TEXT,
"PAYER_STATE": FieldType.FREE_TEXT,
"CONTRACT_AMENDMENT_NUM": FieldType.FREE_TEXT,
}
# Signal names used as keys throughout the confidence package.
EXACT = "exact"
TOKEN_OVERLAP = "token_overlap"
FUZZY = "fuzzy"
NUMBER_MATCH = "number_match"
REGEX = "regex"
GROUNDING = "grounding"
NA_CHECK = "na_check"
ALL_SIGNALS = (EXACT, TOKEN_OVERLAP, FUZZY, NUMBER_MATCH, REGEX, GROUNDING, NA_CHECK)
# Per-FieldType weight presets. Weights are relative; the scorer renormalizes
# to whatever signals actually fire for a given (value, context) pair.
WEIGHT_PRESETS: Dict[FieldType, Dict[str, float]] = {
FieldType.DATE: {
REGEX: 0.30,
NUMBER_MATCH: 0.25,
GROUNDING: 0.25,
EXACT: 0.05,
FUZZY: 0.05,
NA_CHECK: 0.10,
},
FieldType.MONEY: {
NUMBER_MATCH: 0.35,
GROUNDING: 0.25,
REGEX: 0.20,
TOKEN_OVERLAP: 0.05,
EXACT: 0.05,
NA_CHECK: 0.10,
},
FieldType.TIN: {
EXACT: 0.35,
REGEX: 0.30,
NUMBER_MATCH: 0.20,
GROUNDING: 0.10,
NA_CHECK: 0.05,
},
FieldType.NPI: {
EXACT: 0.35,
REGEX: 0.30,
NUMBER_MATCH: 0.20,
GROUNDING: 0.10,
NA_CHECK: 0.05,
},
FieldType.CODE: {
EXACT: 0.40,
REGEX: 0.20,
GROUNDING: 0.20,
TOKEN_OVERLAP: 0.10,
NA_CHECK: 0.10,
},
FieldType.NAME: {
FUZZY: 0.30,
GROUNDING: 0.30,
TOKEN_OVERLAP: 0.20,
EXACT: 0.10,
NA_CHECK: 0.10,
},
FieldType.BOOLEAN: {
EXACT: 0.40,
GROUNDING: 0.30,
NA_CHECK: 0.30,
},
FieldType.FREE_TEXT: {
TOKEN_OVERLAP: 0.30,
FUZZY: 0.25,
GROUNDING: 0.25,
EXACT: 0.10,
NA_CHECK: 0.10,
},
}
def get_field_type(field_name: str) -> FieldType:
"""Resolve a field name to its FieldType. Defaults to FREE_TEXT."""
return FIELD_TYPE_MAP.get(field_name, FieldType.FREE_TEXT)
def get_weight_preset(field_type: FieldType) -> Dict[str, float]:
"""Return the (signal -> weight) preset for a field type."""
return WEIGHT_PRESETS[field_type]
+299
View File
@@ -0,0 +1,299 @@
"""Combine rule-based signals into a per-field confidence score.
Inputs:
field_name : the HSC field name (used to pick FieldType + keywords)
value : the LLM-extracted value
metadata : dict from prompt_hsc_single_field; carries the
retrieved-chunks text and the LLM-stated confidence
contract_text : full cleaned contract text (for cross-checking)
Output:
final score in [0.0, 1.0], rounded to 4 decimals.
Method:
1. Resolve FieldType -> weight preset.
2. Compute each signal that has non-zero weight for that type.
Skip signals that return None (not applicable for this value).
3. Renormalize weights over the signals that fired.
4. Weighted average -> rule_score in [0, 1].
5. Blend lightly with the LLM-stated confidence as a smoothing prior.
(T3 will replace this prior with a calibrated LLM verifier.)
"""
from __future__ import annotations
import logging
import re
from typing import Optional
from src.qc_qa.confidence import calibration as cal
from src.qc_qa.confidence import field_types as ft
from src.qc_qa.confidence import signals as sig
# How much weight to give the LLM-stated confidence in the final blend.
# Low on purpose: the rules dominate. T3 will replace this with a calibrated
# verifier.
_LLM_PRIOR_WEIGHT = 0.15
logger = logging.getLogger(__name__)
def _coerce_float(value: object) -> Optional[float]:
if value is None:
return None
try:
out = float(value)
except (TypeError, ValueError):
return None
if out != out: # NaN
return None
return max(0.0, min(1.0, out))
_STOPWORDS = frozenset(
{
# Interrogatives / connectives we keep filtering out.
"what",
"where",
"when",
"which",
"whose",
"there",
"their",
"these",
"those",
"about",
"above",
"below",
"after",
"before",
"again",
"further",
"between",
"during",
"until",
"while",
"could",
"would",
"should",
"shall",
"might",
# Common short stopwords that slip past length >= 4 but carry no
# domain meaning, so they shouldn't contribute to na_check hits.
"this",
"that",
"with",
"from",
"into",
"have",
"your",
"more",
"only",
"such",
"than",
"they",
"them",
"also",
"each",
"very",
"some",
"most",
"many",
"much",
"into",
"been",
"were",
"will",
"must",
# Contract-boilerplate vocabulary. These appear in nearly every
# healthcare contract regardless of which field is being scored,
# so they're useless as na_check evidence and would otherwise
# flag every doc as suspicious.
"contract",
"contracts",
"contractual",
"agreement",
"agreements",
"document",
"documents",
"provider",
"providers",
"payer",
"payers",
"party",
"parties",
"service",
"services",
"section",
"sections",
"article",
"articles",
"paragraph",
"term",
"terms",
"covered",
# Prompt-instruction noise (these talk about the prompt, not the
# contract, so they're not useful evidence either way).
"value",
"field",
"given",
"named",
"answer",
"extract",
"extracted",
"return",
"follow",
"rules",
"guidelines",
"instructions",
"letters",
"numeric",
"alphanumeric",
"include",
"including",
"format",
"string",
"below",
}
)
_KEYWORD_RE = re.compile(r"[A-Za-z][A-Za-z\-]{3,}")
def _field_keywords(field_name: str, field_prompt: str = "") -> list[str]:
"""Keyword set for the na_check signal.
Uses the field name as the keyword source. Field names like
CONTRACT_AMENDMENT_NUM are terse and domain-specific by design --
split on underscores, drop boilerplate ("contract"), drop short
tokens ("num"), and what's left is the actual concept ("amendment").
That's a far cleaner anti-hallucination signal than scanning a
verbose prompt that drags in generic English ("written", "letters",
"title", "numeric") that appears in every contract regardless.
The prompt is a TRUE fallback: only consulted when the field name
yields nothing useful. For every HSC field today, the field name
produces at least one keyword, so the prompt never runs in practice.
Caps the result at 25 keywords.
"""
seen: dict[str, None] = {}
def _add(token: str) -> bool:
"""Add a normalized token; return True when the cap is reached."""
tok = token.lower()
if len(tok) < 4 or tok in _STOPWORDS or tok in seen:
return False
seen[tok] = None
return len(seen) >= 25
# 1. Field name -- preferred source.
if field_name:
for tok in re.split(r"[_\W]+", field_name):
if _add(tok):
return list(seen.keys())
# 2. Prompt -- only as fallback when the field name had nothing
# useful to contribute (rare in practice).
if not seen and field_prompt:
for tok in _KEYWORD_RE.findall(field_prompt):
if _add(tok):
return list(seen.keys())
return list(seen.keys())
def score_field(
field_name: str,
value: object,
metadata: dict | None,
contract_text: str = "",
field_prompt: str = "",
) -> float:
"""Compute the rule-based confidence score for a single field.
Always returns a finite float in [0.0, 1.0]. On any unexpected error,
falls back to the LLM-stated confidence (or 0.5) and logs a warning --
a bad rule score must never crash the pipeline.
"""
metadata = metadata or {}
try:
field_type = ft.get_field_type(field_name)
weights = ft.get_weight_preset(field_type)
retrieved_text = str(metadata.get("retrieved_chunks_text") or "")
keywords = _field_keywords(field_name, field_prompt)
scores: dict[str, float] = {}
if weights.get(ft.EXACT, 0) > 0:
v = sig.signal_exact(value, contract_text or retrieved_text)
if v is not None:
scores[ft.EXACT] = v
if weights.get(ft.TOKEN_OVERLAP, 0) > 0:
v = sig.signal_token_overlap(value, contract_text or retrieved_text)
if v is not None:
scores[ft.TOKEN_OVERLAP] = v
if weights.get(ft.FUZZY, 0) > 0:
# Fuzzy is O(n*m); keep it scoped to retrieved chunks when present.
haystack = retrieved_text or contract_text
v = sig.signal_fuzzy(value, haystack)
if v is not None:
scores[ft.FUZZY] = v
if weights.get(ft.NUMBER_MATCH, 0) > 0:
v = sig.signal_number_match(value, contract_text or retrieved_text)
if v is not None:
scores[ft.NUMBER_MATCH] = v
if weights.get(ft.REGEX, 0) > 0:
v = sig.signal_regex(value, field_type)
if v is not None:
scores[ft.REGEX] = v
if weights.get(ft.GROUNDING, 0) > 0:
v = sig.signal_grounding(value, retrieved_text)
if v is not None:
scores[ft.GROUNDING] = v
if weights.get(ft.NA_CHECK, 0) > 0:
v = sig.signal_na_check(value, contract_text, keywords)
if v is not None:
scores[ft.NA_CHECK] = v
if not scores:
# No signal could fire -- fall back to the LLM's self-reported
# confidence (which we normalize) or a neutral 0.5.
return _coerce_float(metadata.get("confidence")) or 0.5
# Renormalize weights over the signals that actually fired.
total_weight = sum(weights[name] for name in scores)
if total_weight <= 0:
return _coerce_float(metadata.get("confidence")) or 0.5
rule_score = sum(scores[n] * weights[n] for n in scores) / total_weight
# Light blend with the LLM-stated confidence as a prior. The
# rule-based scorer dominates; the prior just smooths very-low
# rule scores when the LLM was very confident (and vice versa).
llm_conf = _coerce_float(metadata.get("confidence"))
if llm_conf is None:
final = rule_score
else:
final = (1 - _LLM_PRIOR_WEIGHT) * rule_score + _LLM_PRIOR_WEIGHT * llm_conf
# Map raw -> empirically-calibrated confidence using the
# per-field-type curve. Identity by default; becomes meaningful
# once the curve is backfilled from QC data.
final = cal.calibrate(field_type, final)
return round(max(0.0, min(1.0, final)), 4)
except Exception as exc: # pragma: no cover - defensive
logger.warning(
"score_field failed for %s; falling back to LLM-stated conf: %s",
field_name,
exc,
)
return _coerce_float((metadata or {}).get("confidence")) or 0.5
+229
View File
@@ -0,0 +1,229 @@
"""Rule-based confidence signals for one-to-one fields.
Each signal returns a float in [0.0, 1.0] or None when not applicable.
None means "this signal could not produce evidence for this (value, context)
pair" -- the scorer skips it and renormalizes weights over the remaining
signals.
Signals:
exact - normalized value is a substring of the context.
token_overlap - share of value tokens present in the context.
fuzzy - SequenceMatcher best-substring similarity.
number_match - all numbers in the value appear in the context.
regex - field-type-aware format check (date, money, TIN, etc.).
grounding - same family as exact/token but scoped to the *retrieved
chunks the LLM actually saw* (much stronger anti-
hallucination signal than scanning the whole contract).
na_check - special-case scoring when the value is N/A: rewards a
correctly-missing field, penalizes a suspicious miss.
"""
from __future__ import annotations
import re
from difflib import SequenceMatcher
from typing import Optional
from src.qc_qa.confidence.field_types import FieldType
_NA_TOKENS = {"", "n/a", "na", "none", "null", "not found"}
_TOKEN_RE = re.compile(r"[A-Za-z0-9]+")
_NUMBER_RE = re.compile(r"\d+(?:[\.,]\d+)?")
# Field-type-aware format regex. Matches the *value*, not the contract.
_FORMAT_PATTERNS = {
FieldType.DATE: re.compile(
r"\b\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4}\b"
r"|\b\d{4}[/\-.]\d{1,2}[/\-.]\d{1,2}\b"
r"|\b(?:january|february|march|april|may|june|july|august|"
r"september|october|november|december)\b",
re.IGNORECASE,
),
FieldType.MONEY: re.compile(
r"\$\s*[\d,]+(?:\.\d{1,2})?"
r"|\b\d{1,3}(?:,\d{3})+(?:\.\d+)?\b"
r"|\b\d+(?:\.\d+)?\s*(?:USD|dollars?)\b",
re.IGNORECASE,
),
FieldType.TIN: re.compile(r"^\s*\d{2}-?\d{7}\s*$"),
FieldType.NPI: re.compile(r"^\s*\d{10}\s*$"),
FieldType.CODE: re.compile(r"^[A-Z0-9.\-]{1,12}$"),
FieldType.BOOLEAN: re.compile(
r"^\s*(?:y|n|yes|no|true|false|0|1)\s*$", re.IGNORECASE
),
}
def _normalize(text: str) -> str:
"""Lowercase + collapse whitespace. Used for substring/match comparisons."""
return re.sub(r"\s+", " ", text.strip().lower())
def is_na(value: object) -> bool:
"""True when the extracted value should be treated as 'no answer'.
Catches all the shapes a "missing" value can take coming out of the
pipeline: Python None, the standard N/A string tokens, AND pandas /
float NaN (which arrives as a float when the value column was empty
in the dataframe). Float NaN is the only float that is not equal to
itself, which is the cheapest portable check.
"""
if value is None:
return True
# Float NaN check before any str() coercion (str(float('nan')) == 'nan',
# which would otherwise slip past the token check below).
if isinstance(value, float) and value != value:
return True
if isinstance(value, (list, tuple, set)):
return all(is_na(item) for item in value)
return _normalize(str(value)) in _NA_TOKENS
def _stringify(value: object) -> str:
"""Best-effort coercion of a value to a single string for matching."""
if isinstance(value, (list, tuple, set)):
return " ".join(_stringify(v) for v in value)
return str(value) if value is not None else ""
# ----------------------------- signals -------------------------------------
def signal_exact(value: object, context: str) -> Optional[float]:
"""1.0 if the normalized value appears verbatim in the context, else 0.0."""
if not context or is_na(value):
return None
needle = _normalize(_stringify(value))
if not needle:
return None
return 1.0 if needle in _normalize(context) else 0.0
def signal_token_overlap(value: object, context: str) -> Optional[float]:
"""Share of value tokens (alphanumeric) present in the context."""
if not context or is_na(value):
return None
value_tokens = [t.lower() for t in _TOKEN_RE.findall(_stringify(value))]
if not value_tokens:
return None
haystack_tokens = {t.lower() for t in _TOKEN_RE.findall(context)}
hits = sum(1 for t in value_tokens if t in haystack_tokens)
return hits / len(value_tokens)
def signal_fuzzy(value: object, context: str) -> Optional[float]:
"""Best fuzzy substring similarity between the value and the context.
Uses SequenceMatcher on the lowercased strings. Inexpensive on the
capped chunk text we feed it. For very long contracts we'd want a
sliding window, but for retrieved-chunks blobs the full match is fine.
"""
if not context or is_na(value):
return None
needle = _normalize(_stringify(value))
if not needle:
return None
haystack = _normalize(context)
return SequenceMatcher(None, needle, haystack).ratio()
def signal_number_match(value: object, context: str) -> Optional[float]:
"""Share of numbers in the value that also appear in the context.
Useful for dates / money / TINs / NPIs where the "right answer" is a
set of digits. Returns None for values that don't contain any numbers
(the signal isn't applicable).
"""
if not context or is_na(value):
return None
value_numbers = _NUMBER_RE.findall(_stringify(value))
if not value_numbers:
return None
haystack = context
hits = sum(1 for n in value_numbers if n in haystack)
return hits / len(value_numbers)
def signal_regex(value: object, field_type: FieldType) -> Optional[float]:
"""Does the value match the format expected for its field type?
Returns None for FREE_TEXT and NAME (no meaningful format check).
"""
if is_na(value):
return None
pattern = _FORMAT_PATTERNS.get(field_type)
if pattern is None:
return None
candidate = _stringify(value).strip()
return 1.0 if pattern.search(candidate) else 0.0
def signal_grounding(value: object, retrieved_chunks_text: str) -> Optional[float]:
"""How well does the value match the chunks the LLM actually saw?
This is the strongest anti-hallucination signal: if the LLM produced
a value that isn't in its retrieved context, it likely made it up.
Combines exact substring (worth most) with fuzzy similarity (forgiving
on minor edits / capitalization / spacing).
"""
if not retrieved_chunks_text or is_na(value):
return None
needle = _normalize(_stringify(value))
if not needle:
return None
haystack = _normalize(retrieved_chunks_text)
if needle in haystack:
return 1.0
# Fall back to fuzzy: long haystack penalizes raw ratio, so use
# find_longest_match against a heuristic window.
fuzzy = SequenceMatcher(None, needle, haystack).ratio()
# Boost slightly when most value tokens are present even if not in order.
value_tokens = [t.lower() for t in _TOKEN_RE.findall(_stringify(value))]
if value_tokens:
haystack_tokens = {t.lower() for t in _TOKEN_RE.findall(haystack)}
token_hit_rate = sum(1 for t in value_tokens if t in haystack_tokens) / len(
value_tokens
)
return max(fuzzy, 0.7 * token_hit_rate)
return fuzzy
def signal_na_check(
value: object,
contract_text: str,
field_keywords: Optional[list[str]] = None,
) -> Optional[float]:
"""Score legitimate vs suspicious 'N/A' answers.
Returns None for values that are NOT N/A (other signals cover those).
For N/A values, counts total occurrences of the field's keywords in
the contract. A doc that genuinely contains the field's concept will
mention it many times; a doc that doesn't will have zero or near-zero
mentions. The score is continuous, not bucketed, so an amendment doc
that mentions "amendment" four times scores meaningfully lower (more
suspicious) than one that mentions it once in passing.
Score ramp:
0 hits -> 0.85 (the absence looks correct)
saturation -> 0.20 (the contract clearly discusses this;
the LLM's N/A is almost certainly wrong)
"""
if not is_na(value):
return None
if not contract_text or not field_keywords:
# Without keywords we can't tell suspicious from legitimate; stay
# neutral rather than guess.
return 0.6
haystack = _normalize(contract_text)
# Count total occurrences (not just presence) so frequency carries
# signal. Saturation has a meaningful floor (~30 mentions) because
# contracts often contain a few mentions of any concept in boilerplate
# ("may be amended by written amendment", etc.) without actually being
# about it. Only docs that mention the concept tens of times are
# clearly "about" it; those are the ones whose N/A is suspicious.
total_hits = sum(haystack.count(kw.lower()) for kw in field_keywords if kw)
saturation = max(30, 5 * len(field_keywords))
intensity = min(total_hits / saturation, 1.0)
return 0.85 - 0.65 * intensity
+159
View File
@@ -0,0 +1,159 @@
"""Batch-level confidence reporting.
After all per-file 1:1 scoring is done, this module produces two CSVs:
- CONFIDENCE-SUMMARY.csv : per-field distribution stats
columns: field, n, mean, p10, p50, p90, n_below_threshold,
threshold, pct_below_threshold
- CONFIDENCE-FLAGGED.csv : per-file rows where any field's confidence
landed below the threshold (lowest first)
columns: FILE_NAME, field, confidence, value, threshold
Both are derived from the per-row <FIELD>_CONF columns the rule scorer
writes into the 1:1 results.
Thresholds are read from config (default 0.6) and can be tuned per run
via the confidence_threshold CLI arg.
"""
from __future__ import annotations
import logging
from typing import Iterable, List, Tuple
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
CONF_SUFFIX = "_CONF"
DEFAULT_THRESHOLD = 0.6
def _conf_columns(df: pd.DataFrame) -> List[str]:
return [c for c in df.columns if c.endswith(CONF_SUFFIX) and c != CONF_SUFFIX]
def _field_name_from_conf(col: str) -> str:
"""Strip the _CONF suffix to get the underlying field name."""
return col[: -len(CONF_SUFFIX)]
def _percentile(series: pd.Series, q: float) -> float:
"""np.nanpercentile, but defensive against an all-NaN series."""
arr = pd.to_numeric(series, errors="coerce").to_numpy()
arr = arr[~np.isnan(arr)]
if arr.size == 0:
return float("nan")
return float(np.percentile(arr, q))
def compute_summary(
final_df: pd.DataFrame, threshold: float = DEFAULT_THRESHOLD
) -> pd.DataFrame:
"""Build CONFIDENCE-SUMMARY.csv from the final results dataframe.
Returns an empty DataFrame (correct columns) if there are no _CONF
columns to summarize — callers can skip writing rather than crashing.
"""
cols = [
"field",
"n",
"mean",
"p10",
"p50",
"p90",
"n_below_threshold",
"threshold",
"pct_below_threshold",
]
if final_df is None or final_df.empty:
return pd.DataFrame(columns=cols)
conf_cols = _conf_columns(final_df)
if not conf_cols:
return pd.DataFrame(columns=cols)
rows = []
for col in conf_cols:
series = pd.to_numeric(final_df[col], errors="coerce").dropna()
n = int(series.shape[0])
if n == 0:
rows.append(
{
"field": _field_name_from_conf(col),
"n": 0,
"mean": float("nan"),
"p10": float("nan"),
"p50": float("nan"),
"p90": float("nan"),
"n_below_threshold": 0,
"threshold": threshold,
"pct_below_threshold": float("nan"),
}
)
continue
n_below = int((series < threshold).sum())
rows.append(
{
"field": _field_name_from_conf(col),
"n": n,
"mean": round(float(series.mean()), 4),
"p10": round(_percentile(series, 10), 4),
"p50": round(_percentile(series, 50), 4),
"p90": round(_percentile(series, 90), 4),
"n_below_threshold": n_below,
"threshold": threshold,
"pct_below_threshold": round(100.0 * n_below / n, 2),
}
)
out = pd.DataFrame(rows, columns=cols)
out = out.sort_values(by="pct_below_threshold", ascending=False).reset_index(
drop=True
)
return out
def compute_flagged(
final_df: pd.DataFrame,
threshold: float = DEFAULT_THRESHOLD,
file_name_column: str = "FILE_NAME",
) -> pd.DataFrame:
"""Emit (FILE_NAME, field, confidence, value, threshold) rows for every
cell below the threshold. Reviewer hit list.
"""
cols = ["FILE_NAME", "field", "confidence", "value", "threshold"]
if final_df is None or final_df.empty:
return pd.DataFrame(columns=cols)
conf_cols = _conf_columns(final_df)
if not conf_cols:
return pd.DataFrame(columns=cols)
fn_col = file_name_column if file_name_column in final_df.columns else None
flagged: List[dict] = []
for col in conf_cols:
field = _field_name_from_conf(col)
series = pd.to_numeric(final_df[col], errors="coerce")
below_mask = series < threshold
if not below_mask.any():
continue
below = final_df.loc[below_mask, [c for c in (fn_col, field) if c]]
for idx, row in below.iterrows():
flagged.append(
{
"FILE_NAME": row[fn_col] if fn_col else "",
"field": field,
"confidence": round(float(series.loc[idx]), 4),
"value": "" if field not in below.columns else row.get(field, ""),
"threshold": threshold,
}
)
if not flagged:
return pd.DataFrame(columns=cols)
out = pd.DataFrame(flagged, columns=cols)
out = out.sort_values(by="confidence", ascending=True).reset_index(drop=True)
return out
+34
View File
@@ -922,6 +922,28 @@ def write_local(
quoting=1,
)
return None
elif output_type == "confidence_summary":
output_dir = os.path.join(
config.CONSOLIDATED_OUTPUT_DIRECTORY, run_timestamp, "tracking"
)
os.makedirs(output_dir, exist_ok=True)
df.to_csv(
os.path.join(output_dir, f"{config.BATCH_ID}-CONFIDENCE-SUMMARY.csv"),
index=False,
quoting=1,
)
return None
elif output_type == "confidence_flagged":
output_dir = os.path.join(
config.CONSOLIDATED_OUTPUT_DIRECTORY, run_timestamp, "tracking"
)
os.makedirs(output_dir, exist_ok=True)
df.to_csv(
os.path.join(output_dir, f"{config.BATCH_ID}-CONFIDENCE-FLAGGED.csv"),
index=False,
quoting=1,
)
return None
elif output_type == "dtc":
output_dir = os.path.join(config.CONSOLIDATED_OUTPUT_DIRECTORY, run_timestamp)
os.makedirs(output_dir, exist_ok=True)
@@ -1135,6 +1157,18 @@ def write_s3(
config.S3_CLIENT.put_object(
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
)
elif output_type == "confidence_summary":
output_path = f"{config.BATCH_ID}/{run_timestamp}/tracking/{config.BATCH_ID}-CONFIDENCE-SUMMARY.csv"
csv_buffer = df.to_csv(index=False).encode()
config.S3_CLIENT.put_object(
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
)
elif output_type == "confidence_flagged":
output_path = f"{config.BATCH_ID}/{run_timestamp}/tracking/{config.BATCH_ID}-CONFIDENCE-FLAGGED.csv"
csv_buffer = df.to_csv(index=False).encode()
config.S3_CLIENT.put_object(
Bucket=config.S3_OUTPUT_BUCKET, Key=output_path, Body=csv_buffer
)
elif output_type == "dtc":
output_path = f"{config.BATCH_ID}/{run_timestamp}/{config.BATCH_ID}-DTC.csv"
csv_buffer = df.to_csv(index=False).encode()