339cfc74fc
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
230 lines
9.0 KiB
Python
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
|