Skip to content

Commit 552ce90

Browse files
rsalusclaude
andauthored
feat: LCD-anchored confidence scoring algorithm (v2) (#32)
* feat: add policy data models, confidence scorer, and generic fallback policy - T001: PolicyCriterion + PolicyDefinition Pydantic models with LCD metadata - T002: Weighted LCD compliance scoring algorithm with bypass logic - T003: Generic fallback policy builder for unsupported procedure codes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add policy registry with 5 LCD-backed seed policies - T001: PolicyCriterion + PolicyDefinition data models - T003: Generic fallback policy for unsupported procedure codes - T004: PolicyRegistry with resolve() and generic fallback - T007: 5 seed policies (MRI Lumbar L34220, MRI Brain L37373, TKA L36575, PT L34049, ESI L39240) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update PAFormResponse model and enhance evidence extractor - T005: Add optional policy_id and lcd_reference to PAFormResponse - T006: Evidence extractor accepts PolicyDefinition, includes LCD context in prompts, parses confidence signals Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: wire form generator and analyze endpoint to LCD policy engine - T008: Form generator delegates scoring to ConfidenceScorer, includes policy metadata - generate_form_data now accepts PolicyDefinition instead of dict - Recommendation and confidence_score come from calculate_confidence() - PAFormResponse includes policy_id and lcd_reference fields - T009: Analyze endpoint uses PolicyRegistry.resolve() instead of hardcoded policy - Removed SUPPORTED_PROCEDURE_CODES gate (unknown CPTs get generic policy) - Removed EXAMPLE_POLICY import and dead _build_field_mappings helper - evidence_extractor accepts PolicyDefinition | dict via _normalize_criteria() - T010: All 34 tests pass Prerequisite modules created: - src/models/policy.py: PolicyCriterion, PolicyDefinition - src/reasoning/confidence_scorer.py: ScoreResult, calculate_confidence - src/policies/generic_policy.py: build_generic_policy - src/policies/registry.py: PolicyRegistry with 5 seed LCD policies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 205c246 commit 552ce90

24 files changed

Lines changed: 1275 additions & 168 deletions

apps/gateway/Gateway.API/Services/PostgresPARequestStore.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,17 +301,21 @@ private async Task<string> GenerateIdAsync(CancellationToken ct)
301301
await IdGenerationLock.WaitAsync(ct).ConfigureAwait(false);
302302
try
303303
{
304-
var maxId = await _context.PriorAuthRequests
304+
// Filter to only sequential PA-NNN IDs (exclude PA-DEMO-* etc.)
305+
var sequentialIds = await _context.PriorAuthRequests
305306
.AsNoTracking()
306307
.Select(e => e.Id)
307-
.OrderByDescending(id => id)
308-
.FirstOrDefaultAsync(ct)
308+
.Where(id => id.StartsWith("PA-") && id.Length <= 7)
309+
.ToListAsync(ct)
309310
.ConfigureAwait(false);
310311

311312
var counter = 1;
312-
if (maxId is not null && maxId.StartsWith("PA-") && int.TryParse(maxId[3..], out var existing))
313+
foreach (var id in sequentialIds)
313314
{
314-
counter = existing + 1;
315+
if (int.TryParse(id[3..], out var existing) && existing >= counter)
316+
{
317+
counter = existing + 1;
318+
}
315319
}
316320

317321
return $"PA-{counter:D3}";

apps/intelligence/src/api/analyze.py

Lines changed: 6 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,12 @@
1212
from src.models.clinical_bundle import ClinicalBundle
1313
from src.models.pa_form import PAFormResponse
1414
from src.parsers.pdf_parser import parse_pdf
15-
from src.policies.example_policy import EXAMPLE_POLICY
15+
from src.policies.registry import registry
1616
from src.reasoning.evidence_extractor import extract_evidence
1717
from src.reasoning.form_generator import generate_form_data
1818

1919
router = APIRouter()
2020

21-
# Supported procedure codes (MRI Lumbar Spine)
22-
SUPPORTED_PROCEDURE_CODES = {"72148", "72149", "72158"}
23-
2421

2522
class AnalyzeRequest(BaseModel):
2623
"""Request payload for analysis endpoint."""
@@ -36,14 +33,8 @@ async def analyze(request: AnalyzeRequest) -> PAFormResponse:
3633
Analyze clinical data and generate PA form response.
3734
3835
Uses LLM to extract evidence from clinical data and generate PA form.
36+
Resolves policy from registry; unknown CPT codes fall back to generic policy.
3937
"""
40-
# Check if procedure is supported
41-
if request.procedure_code not in SUPPORTED_PROCEDURE_CODES:
42-
raise HTTPException(
43-
status_code=400,
44-
detail=f"Procedure code {request.procedure_code} not supported",
45-
)
46-
4738
# Parse clinical data into structured format
4839
bundle = ClinicalBundle.from_dict(request.patient_id, request.clinical_data)
4940

@@ -55,8 +46,8 @@ async def analyze(request: AnalyzeRequest) -> PAFormResponse:
5546
detail="patient.birth_date is required",
5647
)
5748

58-
# Load policy with requested procedure code
59-
policy = {**EXAMPLE_POLICY, "procedure_codes": [request.procedure_code]}
49+
# Resolve policy from registry (no more 400 rejection for unsupported CPTs)
50+
policy = registry.resolve(request.procedure_code)
6051

6152
# Extract evidence using LLM
6253
evidence = await extract_evidence(bundle, policy)
@@ -87,21 +78,11 @@ async def analyze_with_documents(
8778
except json.JSONDecodeError as e:
8879
raise HTTPException(status_code=400, detail=f"Invalid clinical data JSON: {e}")
8980

90-
# Check if procedure is supported
91-
if procedure_code not in SUPPORTED_PROCEDURE_CODES:
92-
raise HTTPException(
93-
status_code=400,
94-
detail=f"Procedure code {procedure_code} not supported",
95-
)
96-
97-
# Parse clinical data into structured format
9881
bundle = ClinicalBundle.from_dict(patient_id, clinical_data_dict)
9982

10083
# Read all document bytes, then parse PDFs in parallel
10184
pdf_bytes_list = [await doc.read() for doc in documents]
102-
10385
document_texts = list(await asyncio.gather(*[parse_pdf(b) for b in pdf_bytes_list]))
104-
10586
bundle.document_texts = document_texts
10687

10788
# Validate required patient data
@@ -112,8 +93,8 @@ async def analyze_with_documents(
11293
detail="patient.birth_date is required",
11394
)
11495

115-
# Load policy with requested procedure code
116-
policy = {**EXAMPLE_POLICY, "procedure_codes": [procedure_code]}
96+
# Resolve policy from registry
97+
policy = registry.resolve(procedure_code)
11798

11899
# Extract evidence using LLM
119100
evidence = await extract_evidence(bundle, policy)
@@ -122,30 +103,3 @@ async def analyze_with_documents(
122103
form_response = await generate_form_data(bundle, evidence, policy)
123104

124105
return form_response
125-
126-
127-
def _build_field_mappings(bundle: ClinicalBundle, procedure_code: str) -> dict[str, str]:
128-
"""Build PDF field mappings from clinical bundle."""
129-
patient_name = bundle.patient.name if bundle.patient else "Unknown"
130-
patient_dob = (
131-
bundle.patient.birth_date.isoformat()
132-
if bundle.patient and bundle.patient.birth_date
133-
else "Unknown"
134-
)
135-
member_id = (
136-
bundle.patient.member_id
137-
if bundle.patient and bundle.patient.member_id
138-
else "Unknown"
139-
)
140-
diagnosis_codes = ", ".join(c.code for c in bundle.conditions) if bundle.conditions else ""
141-
142-
return {
143-
"PatientName": patient_name,
144-
"PatientDOB": patient_dob,
145-
"MemberID": member_id,
146-
"DiagnosisCodes": diagnosis_codes,
147-
"ProcedureCode": procedure_code,
148-
"ClinicalSummary": "Awaiting production configuration",
149-
"ProviderSignature": "",
150-
"Date": "",
151-
}

apps/intelligence/src/models/pa_form.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@ class PAFormResponse(BaseModel):
3232
field_mappings: dict[str, str] = Field(
3333
description="PDF field name to value mappings"
3434
)
35+
policy_id: str | None = Field(default=None, description="Policy identifier")
36+
lcd_reference: str | None = Field(
37+
default=None, description="LCD article reference"
38+
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Policy data models for LCD-backed prior authorization criteria."""
2+
3+
from pydantic import BaseModel
4+
5+
6+
class PolicyCriterion(BaseModel):
7+
"""A single criterion from a coverage policy."""
8+
9+
id: str
10+
description: str
11+
weight: float # 0.0-1.0, clinical importance
12+
required: bool = False # Hard gate — if NOT_MET, caps score
13+
lcd_section: str | None = None # e.g. "L34220 §4.2"
14+
bypasses: list[str] = [] # criterion IDs this one bypasses when MET
15+
16+
17+
class PolicyDefinition(BaseModel):
18+
"""Complete policy definition with LCD metadata."""
19+
20+
policy_id: str
21+
policy_name: str
22+
lcd_reference: str | None = None # e.g. "L34220"
23+
lcd_title: str | None = None
24+
lcd_contractor: str | None = None
25+
payer: str
26+
procedure_codes: list[str]
27+
diagnosis_codes: list[str] = []
28+
criteria: list[PolicyCriterion]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Generic fallback policy for unsupported procedure codes."""
2+
3+
from src.models.policy import PolicyCriterion, PolicyDefinition
4+
5+
6+
def build_generic_policy(procedure_code: str) -> PolicyDefinition:
7+
"""Build a generic medical necessity policy for any procedure code."""
8+
return PolicyDefinition(
9+
policy_id=f"generic-{procedure_code}",
10+
policy_name="General Medical Necessity",
11+
lcd_reference=None,
12+
payer="General",
13+
procedure_codes=[procedure_code],
14+
diagnosis_codes=[],
15+
criteria=[
16+
PolicyCriterion(
17+
id="medical_necessity",
18+
description="Medical necessity is documented with clinical rationale",
19+
weight=0.40,
20+
required=True,
21+
),
22+
PolicyCriterion(
23+
id="diagnosis_present",
24+
description="Valid diagnosis code is present and supports the procedure",
25+
weight=0.30,
26+
required=True,
27+
),
28+
PolicyCriterion(
29+
id="conservative_therapy",
30+
description="Conservative therapy attempted or documented as not applicable",
31+
weight=0.30,
32+
required=False,
33+
),
34+
],
35+
)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Policy registry for resolving procedure codes to policy definitions."""
2+
3+
from src.models.policy import PolicyDefinition
4+
from src.policies.generic_policy import build_generic_policy
5+
6+
7+
class PolicyRegistry:
8+
"""Resolves procedure codes to LCD-backed policy definitions."""
9+
10+
def __init__(self) -> None:
11+
self._by_cpt: dict[str, PolicyDefinition] = {}
12+
13+
def register(self, policy: PolicyDefinition) -> None:
14+
for cpt in policy.procedure_codes:
15+
self._by_cpt[cpt] = policy
16+
17+
def resolve(self, procedure_code: str) -> PolicyDefinition:
18+
"""Return LCD-backed policy if available, else generic fallback."""
19+
if procedure_code in self._by_cpt:
20+
return self._by_cpt[procedure_code]
21+
return build_generic_policy(procedure_code)
22+
23+
24+
# Module-level singleton
25+
registry = PolicyRegistry()
26+
27+
# Import seed policies to register them
28+
from src.policies.seed import register_all_seeds # noqa: E402
29+
30+
register_all_seeds(registry)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Seed policy loader."""
2+
from src.policies.seed.mri_lumbar import POLICY as MRI_LUMBAR
3+
from src.policies.seed.mri_brain import POLICY as MRI_BRAIN
4+
from src.policies.seed.tka import POLICY as TKA
5+
from src.policies.seed.physical_therapy import POLICY as PHYSICAL_THERAPY
6+
from src.policies.seed.epidural_steroid import POLICY as EPIDURAL_STEROID
7+
8+
ALL_SEED_POLICIES = [MRI_LUMBAR, MRI_BRAIN, TKA, PHYSICAL_THERAPY, EPIDURAL_STEROID]
9+
10+
11+
def register_all_seeds(registry) -> None:
12+
for policy in ALL_SEED_POLICIES:
13+
registry.register(policy)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Epidural Steroid Injection seed policy — LCD L39240."""
2+
3+
from src.models.policy import PolicyCriterion, PolicyDefinition
4+
5+
POLICY = PolicyDefinition(
6+
policy_id="lcd-esi-L39240",
7+
policy_name="Epidural Steroid Injection",
8+
lcd_reference="L39240",
9+
lcd_title="Epidural Steroid Injections",
10+
lcd_contractor="Noridian Healthcare Solutions",
11+
payer="CMS Medicare",
12+
procedure_codes=["62322", "62323"],
13+
diagnosis_codes=["M54.10", "M54.16", "M54.17", "M48.06"],
14+
criteria=[
15+
PolicyCriterion(
16+
id="diagnosis_confirmed",
17+
description="Radiculopathy/stenosis confirmed by history, exam, and imaging",
18+
weight=0.25,
19+
required=True,
20+
lcd_section="L39240 — Requirement 1",
21+
),
22+
PolicyCriterion(
23+
id="severity_documented",
24+
description="Pain severe enough to impact QoL/function, documented with standardized scale",
25+
weight=0.20,
26+
required=True,
27+
lcd_section="L39240 — Requirement 2",
28+
),
29+
PolicyCriterion(
30+
id="conservative_care_4wk",
31+
description="4 weeks conservative care failed/intolerable (except acute herpes zoster)",
32+
weight=0.25,
33+
required=True,
34+
lcd_section="L39240 — Requirement 3",
35+
),
36+
PolicyCriterion(
37+
id="frequency_within_limits",
38+
description="<=4 sessions per region per rolling 12 months",
39+
weight=0.15,
40+
required=True,
41+
lcd_section="L39240 — Frequency Limits",
42+
),
43+
PolicyCriterion(
44+
id="image_guidance_planned",
45+
description="Fluoroscopy or CT guidance with contrast planned",
46+
weight=0.15,
47+
required=True,
48+
lcd_section="L39240 — Procedural Requirements",
49+
),
50+
],
51+
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""MRI Brain seed policy — LCD L37373."""
2+
3+
from src.models.policy import PolicyCriterion, PolicyDefinition
4+
5+
POLICY = PolicyDefinition(
6+
policy_id="lcd-mri-brain-L37373",
7+
policy_name="MRI Brain",
8+
lcd_reference="L37373",
9+
lcd_title="Magnetic Resonance Imaging of the Brain",
10+
lcd_contractor="Noridian Healthcare Solutions",
11+
payer="CMS Medicare",
12+
procedure_codes=["70551", "70552", "70553"],
13+
diagnosis_codes=["G40.909", "R51.9", "G43.909", "G35"],
14+
criteria=[
15+
PolicyCriterion(
16+
id="diagnosis_present",
17+
description="Valid ICD-10 for neurological condition",
18+
weight=0.15,
19+
required=True,
20+
lcd_section="L37373 / A57204 — Covered Diagnoses",
21+
),
22+
PolicyCriterion(
23+
id="neurological_indication",
24+
description="Tumor, stroke, MS, seizures, unexplained neuro deficit",
25+
weight=0.35,
26+
required=True,
27+
lcd_section="L37373 — Indications for MRI",
28+
),
29+
PolicyCriterion(
30+
id="ct_insufficient",
31+
description="CT already performed and insufficient, or MRI specifically indicated",
32+
weight=0.25,
33+
required=False,
34+
lcd_section="L37373 — MRI vs CT Selection",
35+
),
36+
PolicyCriterion(
37+
id="clinical_documentation",
38+
description="Supporting clinical findings documented",
39+
weight=0.25,
40+
required=True,
41+
lcd_section="L37373 — Coverage Requirements",
42+
),
43+
],
44+
)

0 commit comments

Comments
 (0)