feat(sp31): expose keys_matched/candidate_count in Match dataclass and ActivityEvent payload, fix broken pre-existing test

This commit is contained in:
Nora
2026-07-02 16:23:42 -06:00
parent ab91449e62
commit 18fa119ff7
3 changed files with 121 additions and 55 deletions
+63 -18
View File
@@ -17,7 +17,7 @@ Match algorithm:
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Iterable, Optional, Protocol
@@ -50,6 +50,18 @@ class Match:
remittance: _RemitLike
strategy: str
is_reversal: bool
# SP31: which content-keys the 2-of-3 (or 3-of-3) rule agreed on.
# Populated for ``score-auto`` matches; empty for ``pcn-exact`` /
# ``manual`` (PCN-exact only uses the PCN key by definition; manual
# is operator-driven). Floats into the ``auto_matched_835``
# ActivityEvent payload so the audit trail records *why* the link
# fired. Transient — the ORM Match row does not persist these
# (spec keeps the audit trail in ActivityEvent.payload_json only).
keys_matched: set[str] = field(default_factory=set)
# SP31: how many candidates were in the ±30-day window pool when
# the score-auto match fired. Useful when an operator is reviewing
# a borderline auto-link — 1-of-1 is unambiguous; 1-of-5 was lucky.
candidate_count: int = 0
def match(
@@ -123,8 +135,14 @@ CHARGE_TOLERANCE = Decimal("0.01")
KEYS_REQUIRED = 2
def _content_keys_match(remit, claim) -> bool:
"""Return True when at least KEYS_REQUIRED of {PCN, charge, NPI} agree.
def _content_keys_match(remit, claim) -> set[str]:
"""Return the set of content-keys that agree between ``remit`` and ``claim``.
Compares {``pcn``, ``charge``, ``npi``} between the two sides and returns
the subset whose values match. Caller checks ``len(returned) >= KEYS_REQUIRED``
to decide whether to auto-link. Returning the matched keys (rather than a
bool) lets the ActivityEvent payload record exactly which 2-of-3 (or 3-of-3)
produced the auto-link — the audit trail for the SP31 content-keys rule.
Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift).
NPI counts as "not matched" when the remit's NPI is empty.
@@ -143,12 +161,12 @@ def _content_keys_match(remit, claim) -> bool:
The ``raw_json`` fallback is what makes this helper safe against a
raw ``Remittance`` ORM instance from ``reconcile.run()``.
"""
keys_matched = 0
matched: set[str] = set()
# PCN: payer_claim_control_number ↔ patient_control_number (stripped).
if (getattr(remit, "payer_claim_control_number", "") or "").strip() == \
(getattr(claim, "patient_control_number", "") or "").strip():
keys_matched += 1
matched.add("pcn")
# Charge: prefer the planned attribute names, fall back to real ORM columns,
# then to raw_json (where the 835 parser stores CLP03). Explicit ``is None``
@@ -170,7 +188,7 @@ def _content_keys_match(remit, claim) -> bool:
if _remit_charge is not None and _claim_charge is not None and \
abs(_remit_charge - _claim_charge) < CHARGE_TOLERANCE:
keys_matched += 1
matched.add("charge")
# NPI: prefer attribute, fall back to raw_json (where the 835 parser
# stores rendering_provider_npi). The Remittance ORM has no NPI column,
@@ -186,9 +204,9 @@ def _content_keys_match(remit, claim) -> bool:
or ((getattr(claim, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
).strip()
if remit_npi and remit_npi == claim_npi:
keys_matched += 1
matched.add("npi")
return keys_matched >= KEYS_REQUIRED
return matched
# --- SP31: DB-side fallback candidate matcher -------------------------------
@@ -196,8 +214,8 @@ def _content_keys_match(remit, claim) -> bool:
SCORE_FALLBACK_WINDOW_DAYS = 30
def _score_fallback_candidates(session, remit) -> Optional[str]:
"""Return a unique matching claim_id via the 2-of-3 content-keys rule.
def _score_fallback_candidates(session, remit) -> Optional[tuple[str, set[str], int]]:
"""Return (claim_id, keys_matched, candidate_count) for a unique 2-of-3 match.
Queries a ±SCORE_FALLBACK_WINDOW_DAYS candidate pool filtered by:
- claim.matched_remittance_id IS NULL (not yet linked)
@@ -205,8 +223,15 @@ def _score_fallback_candidates(session, remit) -> Optional[str]:
REVERSED, RECONCILED)
- claim.service_date_from within window of remit.service_date
Returns the unique claim.id when exactly one candidate passes the
2-of-3 rule (via :func:`_content_keys_match`), otherwise ``None``.
Returns ``(claim_id, keys_matched, candidate_count)`` when exactly one
candidate passes the 2-of-3 rule (via :func:`_content_keys_match`),
otherwise ``None``. ``keys_matched`` is the set returned by the helper
(``{"pcn", "charge"}`` or any 2-of-3 / 3-of-3 combination) — the
ActivityEvent payload records it as the audit trail for *why* the
auto-link fired. ``candidate_count`` is the size of the window pool
(independent of how many matched) so the operator can see whether
the match was 1-of-N or 1-of-1.
Ambiguous matches (2+ candidates) also return ``None`` — they land
in the Inbox Unlinked lane for manual review.
@@ -244,9 +269,16 @@ def _score_fallback_candidates(session, remit) -> Optional[str]:
)
).scalars().all())
matched_ids = [c.id for c in candidates if _content_keys_match(remit, c)]
if len(matched_ids) == 1:
return matched_ids[0]
matched_pairs = [
(c.id, _content_keys_match(remit, c))
for c in candidates
]
matched_pairs = [
(cid, ks) for cid, ks in matched_pairs if len(ks) >= KEYS_REQUIRED
]
if len(matched_pairs) == 1:
cid, keys_matched = matched_pairs[0]
return (cid, keys_matched, len(candidates))
return None # 0 matches OR 2+ (ambiguous)
@@ -399,9 +431,10 @@ def run(session, batch_id: str) -> ReconcileResult:
continue
if getattr(remit, "claim_id", None) is not None:
continue # already linked
matched_claim_id = _score_fallback_candidates(session, remit)
if matched_claim_id is None:
fallback_result = _score_fallback_candidates(session, remit)
if fallback_result is None:
continue
matched_claim_id, keys_matched, candidate_count = fallback_result
# Find the claim object in the unmatched_claims list (it was loaded).
target_claim = next(
(c for c in unmatched_claims if c.id == matched_claim_id),
@@ -412,6 +445,8 @@ def run(session, batch_id: str) -> ReconcileResult:
matches.append(Match(
claim=target_claim, remittance=remit,
strategy="score-auto", is_reversal=remit.is_reversal,
keys_matched=keys_matched,
candidate_count=candidate_count,
))
used_claim_ids.add(target_claim.id)
@@ -461,7 +496,17 @@ def run(session, batch_id: str) -> ReconcileResult:
batch_id=batch_id, claim_id=m.claim.id,
remittance_id=m.remittance.id,
payload_json=(
{"new_state": m.claim.state.value, "strategy": m.strategy}
{
"new_state": m.claim.state.value, "strategy": m.strategy,
# SP31 spec D8: the auto_matched_835 payload records
# which content-keys the 2-of-3 (or 3-of-3) rule
# agreed on, plus how many candidates were in the
# window pool. ``sorted()`` converts the set to a
# JSON-serializable list and pins a deterministic
# order for tests + audit reads.
"keys_matched": sorted(m.keys_matched),
"candidate_count": m.candidate_count,
}
if m.strategy == "score-auto"
else {"new_state": m.claim.state.value}
),