"""Background inference runners for the three ML/LLM stages. Each runner follows the same contract: it executes after the HTTP response via FastAPI BackgroundTasks, opens its own DB session, walks the artifact through pending -> running -> complete|failed, and never raises. The LLM stage functions self-fall-back to deterministic templates when keyless, so a failed artifact always means an unexpected error (preserved on the row for the regenerate UI). Orphaned `running` artifacts are failed at startup. """ import asyncio import json import logging from datetime import date, datetime, timezone from pathlib import Path from fastapi import BackgroundTasks, FastAPI from sqlalchemy import select from app import db from app.claimguard import audit from app.config import Settings from app.llm.documents import extract_pdf_text from app.llm.stages.stage1_diagnostic import generate_diagnostic_report from app.llm.stages.stage2_recommendation import generate_recommendation from app.llm.stages.stage3_adjudication import generate_adjudication from app.ml.base import get_analyzer from app.models import ( AdjudicationSummary, ArtifactStatus, AuditEventType, Claim, ClaimAction, ClaimHistory, ClaimState, DiagnosticReport, Document, DocumentKind, Recommendation, RecommendationNote, ) from app.rag.anonymizer import build_case_summary, make_case_ref from app.rag.indexer import index_case_document from app.rag.retriever import find_similar_cases, get_case_documents from app.workflow.state_machine import apply_transition logger = logging.getLogger("claimflow.inference") ANALYZE_TIMEOUT_S = 30.0 _RUNNABLE = (ArtifactStatus.PENDING, ArtifactStatus.FAILED) # Expected imaging modality by procedure-code prefix; None when no prefix matches # (or the claim is not an imaging claim) — the stage-2 rule engine treats that as # "no expected modality on file". PROCEDURE_MODALITY: dict[str, str] = {"IMG-2": "xray", "IMG-3": "ct", "IMG-4": "mri"} _IMPRESSION_EXCERPT_CHARS = 200 _HISTORY_LIMIT = 25 _RECENT_WINDOW_DAYS = 365 def _expected_modality(claim_type: str, procedure_code: str) -> str | None: if claim_type != "imaging": return None for prefix, modality in PROCEDURE_MODALITY.items(): if procedure_code.startswith(prefix): return modality return None def _latest_complete_report(session, claim_id: int) -> DiagnosticReport | None: return session.scalar( select(DiagnosticReport) .where( DiagnosticReport.claim_id == claim_id, DiagnosticReport.status == ArtifactStatus.COMPLETE, ) .order_by(DiagnosticReport.id.desc()) .limit(1) ) def _latest_complete_note(session, claim_id: int) -> RecommendationNote | None: return session.scalar( select(RecommendationNote) .where( RecommendationNote.claim_id == claim_id, RecommendationNote.status == ArtifactStatus.COMPLETE, ) .order_by(RecommendationNote.id.desc()) .limit(1) ) def _payload_or_empty(payload_json: str | None) -> dict: return json.loads(payload_json) if payload_json else {} async def run_stage1(settings: Settings, report_id: int) -> None: factory = db.get_session_factory() session = factory() try: report = session.get(DiagnosticReport, report_id) if report is None or report.status not in _RUNNABLE: return claim = session.get(Claim, report.claim_id) document = session.get(Document, report.document_id) if report.document_id else None if claim is None or document is None: return report.status = ArtifactStatus.RUNNING session.commit() try: analyzer = get_analyzer(settings) dicom_meta = json.loads(document.dicom_meta_json) if document.dicom_meta_json else None analysis = await asyncio.wait_for( asyncio.to_thread( analyzer.analyze, Path(document.storage_path), declared_modality=document.modality.value if document.modality else None, dicom_meta=dicom_meta, ), timeout=ANALYZE_TIMEOUT_S, ) # Stage-1c report drafting: keyed path runs the vision LLM; keyless it # renders the deterministic fallback — never raises on LLM problems. stage = generate_diagnostic_report( settings, session, claim_id=claim.id, image_path=Path(document.storage_path), image_media_type=document.mime, analysis=analysis, declared_modality=document.modality.value if document.modality else None, ) except Exception as exc: session.rollback() report.status = ArtifactStatus.FAILED report.error = f"{type(exc).__name__}: {exc}" session.commit() logger.exception("stage-1 inference failed for report %s", report_id) return report.payload_json = json.dumps(stage.payload) report.modality = analysis.modality report.modality_confidence = analysis.modality_confidence report.authenticity_verdict = analysis.authenticity_verdict report.authenticity_risk = analysis.authenticity_risk report.generated_by = stage.generated_by report.prompt_version = stage.prompt_version report.fallback_reason = stage.fallback_reason report.requires_mandatory_review = ( stage.requires_mandatory_review or analysis.authenticity_verdict != "authentic" ) report.status = ArtifactStatus.COMPLETE report.completed_at = datetime.now(timezone.utc) audit.append( session, AuditEventType.LLM_CALL, claim_id=claim.id, actor_role="system", payload={ "stage": "imaging_analysis", "backend": analysis.backend, "modality": analysis.modality, "authenticity_verdict": analysis.authenticity_verdict, }, ) if claim.state == ClaimState.SUBMITTED: apply_transition(session, claim, ClaimAction.IMAGING_COMPLETE, actor=None) session.commit() finally: session.close() def _stage2_uploads( settings: Settings, session, claim: Claim ) -> list[tuple[str, str]]: """Extract text from the claim's non-imaging PDF uploads and index it into RAG. Extraction failures yield an empty text (the rule engine reports the gap); RAG indexing failures are logged and never fail the stage. """ documents = session.scalars( select(Document) .where(Document.claim_id == claim.id, Document.kind != DocumentKind.IMAGING) .order_by(Document.id.asc()) ).all() uploads: list[tuple[str, str]] = [] for document in documents: if document.mime != "application/pdf": continue # PDFs only; images and other binaries carry no extractable text try: text, _ok = extract_pdf_text(Path(document.storage_path)) except Exception: logger.exception("pdf text extraction failed for document %s", document.id) text = "" uploads.append((document.filename, text)) if not text: continue try: index_case_document( settings, claimant_id=claim.claimant_id, claim_id=claim.id, doc_type=document.kind.value, filename=document.filename, text=text, ) except Exception: logger.exception("RAG indexing failed for document %s", document.id) return uploads async def run_stage2(settings: Settings, note_id: int) -> None: """Stage-2 recommendation note. No claim transition: the human forwards.""" factory = db.get_session_factory() session = factory() try: note = session.get(RecommendationNote, note_id) if note is None or note.status not in _RUNNABLE: return claim = session.get(Claim, note.claim_id) if claim is None: return note.status = ArtifactStatus.RUNNING note.error = None session.commit() try: claim_fields = { "claim_type": claim.claim_type, "procedure_code": claim.procedure_code, "diagnosis_code": claim.diagnosis_code, "incident_date": ( claim.incident_date.isoformat() if claim.incident_date else None ), "amount_claimed": claim.amount_claimed, } report = _latest_complete_report(session, claim.id) diagnostic_report = _payload_or_empty(report.payload_json) if report else {} uploads = _stage2_uploads(settings, session, claim) stage = generate_recommendation( settings, session, claim_id=claim.id, claim_fields=claim_fields, diagnostic_report=diagnostic_report, uploads=uploads, modality_for_procedure=_expected_modality( claim.claim_type, claim.procedure_code ), ) note.payload_json = json.dumps(stage.payload) note.recommendation = Recommendation(stage.payload["recommendation"]) note.confidence = stage.payload.get("confidence") note.generated_by = stage.generated_by note.prompt_version = stage.prompt_version note.fallback_reason = stage.fallback_reason note.requires_mandatory_review = stage.requires_mandatory_review note.status = ArtifactStatus.COMPLETE note.completed_at = datetime.now(timezone.utc) session.commit() except Exception as exc: session.rollback() note.status = ArtifactStatus.FAILED note.error = f"{type(exc).__name__}: {exc}" session.commit() logger.exception("stage-2 inference failed for note %s", note_id) finally: session.close() def _history_inputs(session, member_id: str | None) -> tuple[list[dict], dict]: rows = ( session.scalars( select(ClaimHistory) .where(ClaimHistory.member_id == member_id) .order_by(ClaimHistory.date_of_service.desc()) .limit(_HISTORY_LIMIT) ).all() if member_id else [] ) history_rows = [ { "date_of_service": ( row.date_of_service.isoformat() if row.date_of_service else None ), "claim_type": row.claim_type, "procedure_code": row.procedure_code, "billed_amount": row.billed_amount, "outcome": row.outcome, } for row in rows ] today = date.today() rejected = sum(1 for row in rows if row.outcome == "rejected") history_stats = { "total": len(rows), "approved": sum(1 for row in rows if row.outcome == "approved"), "rejected": rejected, "recent_12mo": sum( 1 for row in rows if row.date_of_service is not None and (today - row.date_of_service).days <= _RECENT_WINDOW_DAYS ), "prior_rejections": rejected, } return history_rows, history_stats async def run_stage3(settings: Settings, summary_id: int) -> None: """Stage-3 adjudication summary for the insurance agent.""" factory = db.get_session_factory() session = factory() try: summary = session.get(AdjudicationSummary, summary_id) if summary is None or summary.status not in _RUNNABLE: return claim = session.get(Claim, summary.claim_id) if claim is None: return summary.status = ArtifactStatus.RUNNING summary.error = None session.commit() try: note = _latest_complete_note(session, claim.id) specialist_note = _payload_or_empty(note.payload_json) if note else {} report = _latest_complete_report(session, claim.id) diagnostic_report = _payload_or_empty(report.payload_json) if report else {} report_modality = report.modality if report else None history_rows, history_stats = _history_inputs(session, claim.claimant.member_id) impression = str(diagnostic_report.get("impression") or "") query = build_case_summary( modality=report_modality, claim_type=claim.claim_type, procedure_code=claim.procedure_code, diagnosis_code=claim.diagnosis_code, recommendation=specialist_note.get("recommendation"), key_findings=( [impression[:_IMPRESSION_EXCERPT_CHARS]] if impression else [] ), decision="PENDING", ) similar_cases = find_similar_cases( settings, session, query=query, modality=report_modality, exclude_case_ref=make_case_ref(claim.claim_ref), ) claimant_docs = [ (row["filename"], row["text"]) for row in get_case_documents( settings, session, claimant_id=claim.claimant_id, query=claim.description or claim.claim_type, ) ] stage = generate_adjudication( settings, session, claim_id=claim.id, specialist_note=specialist_note, diagnostic_report=diagnostic_report, history_rows=history_rows, history_stats=history_stats, similar_cases=similar_cases, claimant_docs=claimant_docs, ) summary.payload_json = json.dumps(stage.payload) summary.recommendation_lean = stage.payload.get("recommendation_lean") summary.confidence = stage.payload.get("confidence") summary.generated_by = stage.generated_by summary.prompt_version = stage.prompt_version summary.fallback_reason = stage.fallback_reason summary.requires_mandatory_review = stage.requires_mandatory_review summary.status = ArtifactStatus.COMPLETE summary.completed_at = datetime.now(timezone.utc) session.commit() except Exception as exc: session.rollback() summary.status = ArtifactStatus.FAILED summary.error = f"{type(exc).__name__}: {exc}" session.commit() logger.exception("stage-3 inference failed for summary %s", summary_id) finally: session.close() def schedule_stage1( background_tasks: BackgroundTasks, settings: Settings, report_id: int ) -> None: background_tasks.add_task(run_stage1, settings, report_id) def schedule_stage2( background_tasks: BackgroundTasks, settings: Settings, note_id: int ) -> None: background_tasks.add_task(run_stage2, settings, note_id) def schedule_stage3( background_tasks: BackgroundTasks, settings: Settings, summary_id: int ) -> None: background_tasks.add_task(run_stage3, settings, summary_id) def recover_orphans(app: FastAPI) -> int: """Mark artifacts stuck in `running` (crash mid-task) as failed; called at startup.""" factory = db.get_session_factory() session = factory() try: recovered = 0 for model in (DiagnosticReport, RecommendationNote, AdjudicationSummary): orphans = session.scalars( select(model).where(model.status == ArtifactStatus.RUNNING) ).all() for artifact in orphans: artifact.status = ArtifactStatus.FAILED artifact.error = "orphaned at startup (process restarted mid-run)" recovered += len(orphans) session.commit() return recovered finally: session.close()