feat(sp6): compute_lanes — 4 lanes on read with scoring

This commit is contained in:
Tyler
2026-06-20 18:31:01 -06:00
parent 3432c76561
commit cdffb3faf3
2 changed files with 299 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
"""Compute the four Inbox lanes from the DB on read.
SP6 T6.
Lanes:
- rejected: claims whose 999 AK5 set-level response was R/E
- candidates: remits without a matched claim, with scoreable claims
- unmatched: claims still SUBMITTED with no remittance in flight
- done_today: claims that reached a terminal state in the last 24h
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Iterable
from sqlalchemy.orm import Session
from cyclone.db import Claim, ClaimState, Remittance
from cyclone.scoring import score_pair, ScoreBreakdown
@dataclass
class Lanes:
rejected: list[dict] = field(default_factory=list)
candidates: list[dict] = field(default_factory=list)
unmatched: list[dict] = field(default_factory=list)
done_today: list[dict] = field(default_factory=list)
def _isoformat(value) -> str | None:
if value is None:
return None
if hasattr(value, "isoformat"):
return value.isoformat()
return str(value)
def _claim_to_row(c: Claim, *, kind: str, score: ScoreBreakdown | None = None) -> dict:
return {
"id": c.id,
"kind": kind,
"patient_control_number": c.patient_control_number,
"charge_amount": float(c.charge_amount) if c.charge_amount is not None else None,
"payer_id": c.payer_id,
"provider_npi": c.provider_npi,
"state": c.state.value if hasattr(c.state, "value") else str(c.state),
"rejection_reason": c.rejection_reason,
"rejected_at": _isoformat(c.rejected_at),
"service_date_from": _isoformat(c.service_date_from),
"score": score.total if score else None,
"score_tier": score.tier if score else None,
"score_breakdown": {
"patient": score.patient,
"date": score.date,
"amount": score.amount,
"provider": score.provider,
} if score else None,
}
def _remit_to_row(r: Remittance, *, candidates: list[tuple[Claim, ScoreBreakdown]]) -> dict:
raw = r.raw_json or {}
return {
"id": r.id,
"kind": "remit",
"payer_claim_control_number": r.payer_claim_control_number,
"charge_amount": float(r.total_charge) if r.total_charge is not None else None,
"payer_id": raw.get("payer_id"),
"rendering_provider_npi": raw.get("rendering_provider_npi"),
"service_date": _isoformat(r.service_date),
"candidates": [
{
"claim_id": c.id,
"score": s.total,
"tier": s.tier,
"breakdown": {
"patient": s.patient, "date": s.date,
"amount": s.amount, "provider": s.provider,
},
}
for c, s in candidates[:5]
],
}
class _RemitScoringShim:
"""Adapt a Remittance to the duck-typed protocol score_pair expects.
The 835 parser stores service_date on the ORM column. PCN, NPI, payer_id
aren't on Remittance; we read them from raw_json.
"""
def __init__(self, r: Remittance):
raw = r.raw_json or {}
self.patient_control_number = (
raw.get("patient_control_number") or r.payer_claim_control_number
)
self.service_date = r.service_date
self.charge_amount = r.total_charge
self.rendering_provider_npi = raw.get("rendering_provider_npi")
def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes:
lanes = Lanes()
dismissed = set(dismissed_pairs)
# --- Rejected ---
for c in session.query(Claim).filter(Claim.state == ClaimState.REJECTED).all():
lanes.rejected.append(_claim_to_row(c, kind="claim"))
# --- Done today ---
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
terminal_states = {
ClaimState.PAID, ClaimState.PARTIAL, ClaimState.DENIED,
ClaimState.RECONCILED, ClaimState.REVERSED,
}
for c in session.query(Claim).filter(
Claim.state.in_(terminal_states),
Claim.state_changed_at >= cutoff,
).all():
lanes.done_today.append(_claim_to_row(c, kind="claim"))
# --- Unmatched (claims still submitted, no remittance in flight) ---
for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all():
lanes.unmatched.append(_claim_to_row(c, kind="claim"))
# --- Candidates (remits not yet matched, with scoreable claims) ---
unmatched_remits = session.query(Remittance).filter(
Remittance.claim_id.is_(None),
).all()
submitted_claims = list(
session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all()
)
for r in unmatched_remits:
if r.claim_id:
continue
candidates: list[tuple[Claim, ScoreBreakdown]] = []
shim = _RemitScoringShim(r)
for c in submitted_claims:
if c.payer_id and r.raw_json and (r.raw_json.get("payer_id") != c.payer_id):
continue
score = score_pair(c, shim)
if score.tier == "hidden":
continue
if frozenset({c.id, r.id}) in dismissed:
continue
candidates.append((c, score))
candidates.sort(key=lambda cs: -cs[1].total)
if not candidates:
continue
lanes.candidates.append(_remit_to_row(r, candidates=candidates))
return lanes