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
300 lines
9.2 KiB
Python
300 lines
9.2 KiB
Python
"""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
|