Signal/python-backend/core/dedup.py
Kisa c07d054ca5 Add readiness-model dedup + coverage-line grouping foundation
Group normalized shipment rows into deduped shipments and gradeable
coverage lines (patient -> device -> shipment), the structural fix for the
row-keyed worklist bug (six months of orders no longer = six worklist lines).

- Locked dedup key (patient_id, device_type, date_of_service); order_number
  is display-only. Monthly resupplies stay separate, not duplicates.
- Client-mapped plan_type carried through, never guessed from payer name;
  conflicting plan types in a group route to "plan type needed".
- Quantity merge: sum across distinct component configs, max within a config
  (duplicate guard) with a warning breadcrumb on disagreement.
- CGM gradeable this phase; pumps preserved/displayed but not graded.

Additive only: not yet wired into the live pipeline, grading unchanged.
30 tests green (full suite 46), code-auditor reviewed (M1/M2/L1/L2/L3 fixed).

Design: docs/dedup-design-2026-06-24.md, docs/readiness-model-brief-2026-06-23.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:22:01 -04:00

250 lines
9.5 KiB
Python

"""
dedup.py
Signal — STTIL Solutions
Groups normalized shipment rows into deduped shipments and gradeable coverage
lines. This is the structural foundation of the readiness model: it turns a
flat list of CSV-derived rows into the patient -> device -> shipment hierarchy
the worklist needs, BEFORE any grading happens.
Design: docs/dedup-design-2026-06-24.md (Pi, locked by Kisa 2026-06-24)
docs/readiness-model-brief-2026-06-23.md sections 3, 5, 7
LOCKED DEDUP KEY: (patient_id, device_type, date_of_service)
order_number is a displayed attribute, NEVER part of the dedup key.
Monthly resupplies are separate dates of service, NOT duplicates.
PLAN TYPE: carried through verbatim from the client-mapped field. It is NEVER
derived from the payer name. None means "plan type needed" -> keyed as
'unknown' so the line still forms but cannot be graded green later.
PHI CONTRACT: operates only on patient_id, device_type, shipment_date,
quantity, payer (display), plan_type, component, order_number, hcpcs, and
the csv_* doc-status fields. No names, SSNs, DOBs, or contact fields.
"""
from __future__ import annotations
import hashlib
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Optional
from core.coverage_calculator import ShipmentRecord, CoverageFlag
logger = logging.getLogger(__name__)
def _hash_pid(patient_id: str) -> str:
"""Short hashed patient_id for log lines — raw patient_id is never logged."""
return hashlib.sha256(patient_id.encode()).hexdigest()[:12]
def _as_date(value) -> date:
"""Coerce a datetime to a pure date so same-day timestamps group together."""
return value.date() if isinstance(value, datetime) else value
# CGM device types graded this phase. Pumps and other DMEPOS categories are
# preserved and displayed but not graded (gradeable=False). Mirrors the CGM
# devices in payer_rules.json; moves into addressable config in a later step.
CGM_DEVICE_TYPES = frozenset(
{"dexcom_g6", "dexcom_g7", "freestyle_libre_2", "freestyle_libre_3"}
)
UNKNOWN_PLAN_TYPE = "unknown"
def is_cgm_device(device_type: str) -> bool:
"""True only for CGM device types graded in the current phase."""
return device_type in CGM_DEVICE_TYPES
@dataclass
class MergedShipment:
"""One deduped shipment: all rows sharing (patient_id, device_type, DOS)."""
patient_id: str
device_type: str
shipment_date: date
total_quantity: int
components: list[str] # combined, deduped, first-seen order
order_numbers: list[str] # collected, deduped — display only
hcpcs_codes: list[str] # collected, deduped — display only
payer: str # raw display NAME, latest non-null
plan_type: Optional[str] # client-mapped; NEVER from payer name
# csv_* doc fields — latest non-null wins across the group
csv_visit_date: Optional[date] = None
csv_swo_status: Optional[str] = None
csv_pecos_verified: Optional[str] = None
csv_pa_status: Optional[str] = None
csv_diagnosis_on_file: Optional[str] = None
csv_transfer_from: Optional[str] = None
@dataclass(frozen=True)
class CoverageLineKey:
patient_id: str
device_type: str
plan_type_id: str
@dataclass
class CoverageLine:
"""A gradeable unit: one patient's one device under one plan type.
line_status and timing_flag are computed later by the grading step
(gradeLine); a freshly assigned line is intentionally ungraded.
"""
patient_id: str
device_type: str
plan_type: str
gradeable: bool
current_shipment: MergedShipment
prior_shipments: list[MergedShipment] = field(default_factory=list)
line_status: Optional[str] = None
timing_flag: Optional[CoverageFlag] = None
def _qty(record: ShipmentRecord) -> int:
"""A row without a quantity is still a shipment -> default to 1."""
return 1 if record.quantity is None else record.quantity
def _merge_quantities(rows: list[ShipmentRecord]) -> int:
"""Sum quantities across DISTINCT component configs, max within a config.
Within one component config, identical rows are likely data-entry
duplicates, so take the MAX (guards against doubling). Across distinct
configs (a real component split, e.g. sensor + transmitter), SUM.
A disagreement within one config (e.g. qty 3 and qty 100 for the same
component) collapses to the max and leaves a warning, so silently dropping
quantity always leaves an audit breadcrumb.
"""
by_config: dict[frozenset, list[int]] = defaultdict(list)
for row in rows:
by_config[frozenset({row.component})].append(_qty(row))
total = 0
for config, qtys in by_config.items():
if len(set(qtys)) > 1:
logger.warning(
"Quantity disagreement in dedup group patient=%s device=%s "
"component=%s qtys=%s -> taking max",
_hash_pid(rows[0].patient_id), rows[0].device_type,
set(config), qtys,
)
total += max(qtys)
return total
def _merge_plan_type(rows: list[ShipmentRecord]) -> Optional[str]:
"""Plan type is grading-critical and must NEVER be guessed.
If a dedup group carries more than one distinct non-null plan type, the data
is ambiguous, so return None ('plan type needed') rather than pick one.
"""
distinct = {r.plan_type for r in rows if r.plan_type}
if len(distinct) > 1:
logger.warning(
"Conflicting plan_type in dedup group patient=%s device=%s: %s "
"-> routing to 'plan type needed'",
_hash_pid(rows[0].patient_id), rows[0].device_type, sorted(distinct),
)
return None
return next(iter(distinct)) if distinct else None
def _dedup_preserve_order(values: list[Optional[str]]) -> list[str]:
"""Collect non-empty values, dedup, preserve first-seen order."""
seen: dict[str, None] = {}
for v in values:
if v:
seen.setdefault(v, None)
return list(seen.keys())
def _latest_non_null(rows: list[ShipmentRecord], attr: str):
"""Last meaningful value for an attribute, in ingestion order.
Empty / whitespace-only strings are treated as null so a blank later row
never clobbers an earlier real value (does not rely on upstream nulling).
"""
result = None
for row in rows:
value = getattr(row, attr)
if value is None:
continue
if isinstance(value, str) and value.strip() == "":
continue
result = value
return result
def dedup(records: list[ShipmentRecord]) -> list[MergedShipment]:
"""Group rows by (patient_id, device_type, shipment_date) into MergedShipments."""
groups: dict[tuple, list[ShipmentRecord]] = {}
for record in records:
key = (record.patient_id, record.device_type, _as_date(record.shipment_date))
groups.setdefault(key, []).append(record)
merged: list[MergedShipment] = []
for (patient_id, device_type, shipment_date), rows in groups.items():
merged.append(
MergedShipment(
patient_id=patient_id,
device_type=device_type,
shipment_date=shipment_date,
total_quantity=_merge_quantities(rows),
components=_dedup_preserve_order([r.component for r in rows]),
order_numbers=_dedup_preserve_order([r.order_number for r in rows]),
hcpcs_codes=_dedup_preserve_order([r.hcpcs for r in rows]),
payer=_latest_non_null(rows, "payer") or "",
plan_type=_merge_plan_type(rows),
csv_visit_date=_latest_non_null(rows, "csv_visit_date"),
csv_swo_status=_latest_non_null(rows, "csv_swo_status"),
csv_pecos_verified=_latest_non_null(rows, "csv_pecos_verified"),
csv_pa_status=_latest_non_null(rows, "csv_pa_status"),
csv_diagnosis_on_file=_latest_non_null(rows, "csv_diagnosis_on_file"),
csv_transfer_from=_latest_non_null(rows, "csv_transfer_from"),
)
)
merged.sort(key=lambda m: (m.patient_id, m.device_type, m.shipment_date))
return merged
def assign_to_coverage_lines(
merged_shipments: list[MergedShipment],
) -> dict[CoverageLineKey, CoverageLine]:
"""Group MergedShipments into CoverageLines by (patient_id, device_type, plan_type).
plan_type is the client-mapped field. None is keyed as 'unknown' (plan type
needed) so the line still forms but cannot be graded green later. The latest
DOS in each line is the current shipment; earlier ones are collapsed history.
"""
by_key: dict[CoverageLineKey, list[MergedShipment]] = {}
for shipment in merged_shipments:
key = CoverageLineKey(
patient_id=shipment.patient_id,
device_type=shipment.device_type,
plan_type_id=shipment.plan_type or UNKNOWN_PLAN_TYPE,
)
by_key.setdefault(key, []).append(shipment)
lines: dict[CoverageLineKey, CoverageLine] = {}
for key, shipments in by_key.items():
shipments.sort(key=lambda s: s.shipment_date, reverse=True)
lines[key] = CoverageLine(
patient_id=key.patient_id,
device_type=key.device_type,
plan_type=key.plan_type_id,
gradeable=is_cgm_device(key.device_type),
current_shipment=shipments[0],
prior_shipments=shipments[1:],
line_status=None,
timing_flag=None,
)
return lines