Files
doczyai-pipelines/src/qc_qa/confidence/signals.py
T
Faizan Mohiuddin 339cfc74fc 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
2026-05-12 21:53:05 +00:00

230 lines
9.0 KiB
Python

"""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