Spaces:
Runtime error
Runtime error
| """Insurance-agent portal: adjudication queue, full case dossier, decision-email | |
| drafting, and the atomic decide-and-notify endpoint. | |
| The decision endpoint is the assessment's hard requirement: the agent's final decision | |
| and the claimant notification email are ONE action — the workflow transition, the | |
| Decision row, the audit events, and the Notification row all land in a single | |
| transaction with one commit at the end. Precedent indexing (Chroma) is an external | |
| side effect and is best-effort: its failure never fails the decision. | |
| """ | |
| import json | |
| import logging | |
| from datetime import date, datetime | |
| from typing import Literal, TypeVar | |
| from fastapi import APIRouter, Depends, HTTPException | |
| from pydantic import BaseModel | |
| from sqlalchemy import select | |
| from sqlalchemy.orm import Session | |
| from app.auth.deps import enforce_origin, get_settings_dep, require_role | |
| from app.claimguard import audit | |
| from app.config import Settings | |
| from app.db import get_db | |
| from app.llm import fallbacks | |
| from app.llm.stages.claimant_email import draft_claimant_email | |
| from app.models import ( | |
| AdjudicationSummary, | |
| ArtifactStatus, | |
| AuditEventType, | |
| Claim, | |
| ClaimAction, | |
| ClaimHistory, | |
| ClaimState, | |
| Decision, | |
| DiagnosticReport, | |
| Document, | |
| Notification, | |
| RecommendationNote, | |
| Role, | |
| User, | |
| ) | |
| from app.rag.indexer import index_closed_case | |
| from app.services.notifications.service import send_claim_email | |
| from app.workflow import state_machine | |
| from app.workflow.state_machine import TERMINAL_STATES, TransitionError | |
| router = APIRouter() | |
| logger = logging.getLogger("claimflow.agent") | |
| DOSSIER_STATES = TERMINAL_STATES | {ClaimState.ADJUDICATION} | |
| _ArtifactT = TypeVar("_ArtifactT", DiagnosticReport, RecommendationNote, AdjudicationSummary) | |
| # ---------------------------------------------------------------------- response models | |
| class QueueItem(BaseModel): | |
| claim_id: int | |
| claim_ref: str | |
| claim_type: str | |
| claimant: str | |
| state: str | |
| submitted_at: str | |
| summary_status: str | None | |
| recommendation_lean: str | None | |
| confidence: float | None | |
| requires_mandatory_review: bool | None | |
| specialist_recommendation: str | None | |
| class ClaimCore(BaseModel): | |
| id: int | |
| claim_ref: str | |
| claim_type: str | |
| description: str | |
| procedure_code: str | |
| diagnosis_code: str | |
| incident_date: date | None | |
| amount_claimed: float | |
| state: str | |
| created_at: datetime | |
| updated_at: datetime | |
| class ClaimantInfo(BaseModel): | |
| full_name: str | |
| member_id: str | None | |
| preferred_language: str | |
| preferred_tone: str | |
| class DossierDocument(BaseModel): | |
| id: int | |
| filename: str | |
| kind: str | |
| modality: str | None | |
| size_bytes: int | |
| has_preview: bool | |
| class ArtifactOut(BaseModel): | |
| id: int | |
| status: str | |
| payload: dict | None | |
| generated_by: str | |
| fallback_reason: str | None | |
| requires_mandatory_review: bool | |
| created_at: datetime | |
| completed_at: datetime | None | |
| class DiagnosticReportOut(ArtifactOut): | |
| modality: str | None | |
| modality_confidence: float | None | |
| authenticity_verdict: str | None | |
| authenticity_risk: float | None | |
| class RecommendationNoteOut(ArtifactOut): | |
| recommendation: str | None | |
| confidence: float | None | |
| class AdjudicationSummaryOut(ArtifactOut): | |
| recommendation_lean: str | None | |
| confidence: float | None | |
| class HistoryRow(BaseModel): | |
| claim_type: str | |
| procedure_code: str | |
| diagnosis_code: str | |
| modality: str | None | |
| billed_amount: float | |
| outcome: str | |
| date_of_service: date | None | |
| decided_at: date | None | |
| class HistoryStats(BaseModel): | |
| total: int | |
| approved: int | |
| rejected: int | |
| class TimelineEntry(BaseModel): | |
| action: str | |
| from_state: str | None | |
| to_state: str | |
| actor_role: str | |
| note: str | None | |
| created_at: datetime | |
| class NotificationOut(BaseModel): | |
| id: int | |
| subject: str | |
| body_text: str | |
| provider: str | |
| status: str | |
| created_at: datetime | |
| sent_at: datetime | None | |
| class Dossier(BaseModel): | |
| claim: ClaimCore | |
| claimant: ClaimantInfo | |
| documents: list[DossierDocument] | |
| diagnostic_report: DiagnosticReportOut | None | |
| recommendation_note: RecommendationNoteOut | None | |
| adjudication_summary: AdjudicationSummaryOut | None | |
| claim_history: list[HistoryRow] | |
| history_stats: HistoryStats | |
| timeline: list[TimelineEntry] | |
| notifications: list[NotificationOut] | |
| class DraftEmailRequest(BaseModel): | |
| decision: Literal["APPROVED", "REJECTED"] | |
| class DraftEmailOut(BaseModel): | |
| subject: str | |
| greeting: str | |
| body_paragraphs: list[str] | |
| closing: str | |
| generated_by: str | |
| fallback_reason: str | None | |
| class DecisionRequest(BaseModel): | |
| action: Literal["approve", "reject"] | |
| note: str | None = None | |
| email_subject: str = "" | |
| email_body_text: str = "" | |
| class DecisionOut(BaseModel): | |
| state: str | |
| notification_id: int | |
| notification_status: str | |
| case_ref: str | None | |
| # ---------------------------------------------------------------------------- helpers | |
| def _get_claim_or_404(session: Session, claim_id: int) -> Claim: | |
| claim = session.get(Claim, claim_id) | |
| if claim is None: | |
| raise HTTPException(status_code=404, detail="Claim not found") | |
| return claim | |
| def _latest_artifact( | |
| session: Session, model: type[_ArtifactT], claim_id: int | |
| ) -> _ArtifactT | None: | |
| """Latest COMPLETE artifact, or the latest of any status when none is complete.""" | |
| complete = session.scalar( | |
| select(model) | |
| .where(model.claim_id == claim_id, model.status == ArtifactStatus.COMPLETE) | |
| .order_by(model.id.desc()) | |
| .limit(1) | |
| ) | |
| if complete is not None: | |
| return complete | |
| return session.scalar( | |
| select(model).where(model.claim_id == claim_id).order_by(model.id.desc()).limit(1) | |
| ) | |
| def _artifact_payload(payload_json: str | None) -> dict | None: | |
| return json.loads(payload_json) if payload_json else None | |
| def _first_name(full_name: str) -> str: | |
| parts = full_name.split() | |
| return parts[0] if parts else full_name | |
| def _language(value: str) -> Literal["en", "fr"]: | |
| return "fr" if value == "fr" else "en" | |
| def _tone(value: str) -> Literal["formal", "plain_language"]: | |
| return "formal" if value == "formal" else "plain_language" | |
| def _fallback_email_text(claim: Claim, decision: Literal["APPROVED", "REJECTED"]) -> tuple[str, str]: | |
| """Server-side email fallback (deterministic template; the email never blocks the | |
| decision). Returns ``(subject, body_text)``.""" | |
| claimant = claim.claimant | |
| draft = fallbacks.fallback_claimant_email( | |
| decision=decision, | |
| first_name=_first_name(claimant.full_name), | |
| language=_language(claimant.preferred_language), | |
| tone=_tone(claimant.preferred_tone), | |
| claim_ref=claim.claim_ref, | |
| claim_type=claim.claim_type, | |
| ) | |
| body_text = "\n\n".join([draft.greeting, *draft.body_paragraphs, draft.closing]) | |
| return draft.subject, body_text | |
| def _key_findings(note: RecommendationNote | None) -> list[str]: | |
| """PII-free excerpt of the specialist note summary for the precedent index.""" | |
| if note is None or not note.payload_json: | |
| return [] | |
| payload = json.loads(note.payload_json) | |
| summary = str(payload.get("summary") or payload.get("impression") or "").strip() | |
| return [summary[:300]] if summary else [] | |
| # -------------------------------------------------------------------------- endpoints | |
| def adjudication_queue(session: Session = Depends(get_db)) -> list[QueueItem]: | |
| claims = session.scalars( | |
| select(Claim) | |
| .where(Claim.state == ClaimState.ADJUDICATION) | |
| .order_by(Claim.updated_at.asc(), Claim.id.asc()) | |
| ).all() | |
| items: list[QueueItem] = [] | |
| for claim in claims: | |
| summary = _latest_artifact(session, AdjudicationSummary, claim.id) | |
| note = _latest_artifact(session, RecommendationNote, claim.id) | |
| items.append( | |
| QueueItem( | |
| claim_id=claim.id, | |
| claim_ref=claim.claim_ref, | |
| claim_type=claim.claim_type, | |
| claimant=claim.claimant.full_name, | |
| state=claim.state.value, | |
| submitted_at=claim.created_at.isoformat(), | |
| summary_status=summary.status.value if summary is not None else None, | |
| recommendation_lean=summary.recommendation_lean if summary is not None else None, | |
| confidence=summary.confidence if summary is not None else None, | |
| requires_mandatory_review=( | |
| summary.requires_mandatory_review if summary is not None else None | |
| ), | |
| specialist_recommendation=( | |
| note.recommendation.value | |
| if note is not None and note.recommendation is not None | |
| else None | |
| ), | |
| ) | |
| ) | |
| return items | |
| def case_dossier(claim_id: int, session: Session = Depends(get_db)) -> Dossier: | |
| claim = _get_claim_or_404(session, claim_id) | |
| if claim.state not in DOSSIER_STATES: | |
| raise HTTPException( | |
| status_code=409, | |
| detail=( | |
| "dossier requires ADJUDICATION or a terminal state; " | |
| f"claim is {claim.state.value}" | |
| ), | |
| ) | |
| claimant = claim.claimant | |
| documents = session.scalars( | |
| select(Document).where(Document.claim_id == claim.id).order_by(Document.id.asc()) | |
| ).all() | |
| report = _latest_artifact(session, DiagnosticReport, claim.id) | |
| note = _latest_artifact(session, RecommendationNote, claim.id) | |
| summary = _latest_artifact(session, AdjudicationSummary, claim.id) | |
| history_rows: list[ClaimHistory] = [] | |
| outcomes: list[str] = [] | |
| if claimant.member_id: | |
| history_rows = list( | |
| session.scalars( | |
| select(ClaimHistory) | |
| .where(ClaimHistory.member_id == claimant.member_id) | |
| .order_by(ClaimHistory.date_of_service.desc(), ClaimHistory.id.desc()) | |
| .limit(25) | |
| ).all() | |
| ) | |
| outcomes = list( | |
| session.scalars( | |
| select(ClaimHistory.outcome).where(ClaimHistory.member_id == claimant.member_id) | |
| ).all() | |
| ) | |
| decisions = session.scalars( | |
| select(Decision).where(Decision.claim_id == claim.id).order_by(Decision.id.asc()) | |
| ).all() | |
| notifications = session.scalars( | |
| select(Notification).where(Notification.claim_id == claim.id).order_by(Notification.id) | |
| ).all() | |
| def artifact_fields(artifact: DiagnosticReport | RecommendationNote | AdjudicationSummary): | |
| return { | |
| "id": artifact.id, | |
| "status": artifact.status.value, | |
| "payload": _artifact_payload(artifact.payload_json), | |
| "generated_by": artifact.generated_by, | |
| "fallback_reason": artifact.fallback_reason, | |
| "requires_mandatory_review": artifact.requires_mandatory_review, | |
| "created_at": artifact.created_at, | |
| "completed_at": artifact.completed_at, | |
| } | |
| return Dossier( | |
| claim=ClaimCore( | |
| id=claim.id, | |
| claim_ref=claim.claim_ref, | |
| claim_type=claim.claim_type, | |
| description=claim.description, | |
| procedure_code=claim.procedure_code, | |
| diagnosis_code=claim.diagnosis_code, | |
| incident_date=claim.incident_date, | |
| amount_claimed=claim.amount_claimed, | |
| state=claim.state.value, | |
| created_at=claim.created_at, | |
| updated_at=claim.updated_at, | |
| ), | |
| claimant=ClaimantInfo( | |
| full_name=claimant.full_name, | |
| member_id=claimant.member_id, | |
| preferred_language=claimant.preferred_language, | |
| preferred_tone=claimant.preferred_tone, | |
| ), | |
| documents=[ | |
| DossierDocument( | |
| id=d.id, | |
| filename=d.filename, | |
| kind=d.kind.value, | |
| modality=d.modality.value if d.modality else None, | |
| size_bytes=d.size_bytes, | |
| has_preview=d.preview_path is not None, | |
| ) | |
| for d in documents | |
| ], | |
| diagnostic_report=( | |
| DiagnosticReportOut( | |
| **artifact_fields(report), | |
| modality=report.modality, | |
| modality_confidence=report.modality_confidence, | |
| authenticity_verdict=report.authenticity_verdict, | |
| authenticity_risk=report.authenticity_risk, | |
| ) | |
| if report is not None | |
| else None | |
| ), | |
| recommendation_note=( | |
| RecommendationNoteOut( | |
| **artifact_fields(note), | |
| recommendation=note.recommendation.value if note.recommendation else None, | |
| confidence=note.confidence, | |
| ) | |
| if note is not None | |
| else None | |
| ), | |
| adjudication_summary=( | |
| AdjudicationSummaryOut( | |
| **artifact_fields(summary), | |
| recommendation_lean=summary.recommendation_lean, | |
| confidence=summary.confidence, | |
| ) | |
| if summary is not None | |
| else None | |
| ), | |
| claim_history=[ | |
| HistoryRow( | |
| claim_type=row.claim_type, | |
| procedure_code=row.procedure_code, | |
| diagnosis_code=row.diagnosis_code, | |
| modality=row.modality, | |
| billed_amount=row.billed_amount, | |
| outcome=row.outcome, | |
| date_of_service=row.date_of_service, | |
| decided_at=row.decided_at, | |
| ) | |
| for row in history_rows | |
| ], | |
| history_stats=HistoryStats( | |
| total=len(outcomes), | |
| approved=sum(1 for o in outcomes if o == "approved"), | |
| rejected=sum(1 for o in outcomes if o == "rejected"), | |
| ), | |
| timeline=[ | |
| TimelineEntry( | |
| action=d.action.value, | |
| from_state=d.from_state.value if d.from_state else None, | |
| to_state=d.to_state.value, | |
| actor_role=d.actor_role, | |
| note=d.note, | |
| created_at=d.created_at, | |
| ) | |
| for d in decisions | |
| ], | |
| notifications=[ | |
| NotificationOut( | |
| id=n.id, | |
| subject=n.subject, | |
| body_text=n.body_text, | |
| provider=n.provider, | |
| status=n.status.value, | |
| created_at=n.created_at, | |
| sent_at=n.sent_at, | |
| ) | |
| for n in notifications | |
| ], | |
| ) | |
| def draft_decision_email( | |
| claim_id: int, | |
| body: DraftEmailRequest, | |
| user: User = Depends(require_role(Role.INSURANCE_AGENT)), | |
| session: Session = Depends(get_db), | |
| settings: Settings = Depends(get_settings_dep), | |
| ) -> DraftEmailOut: | |
| claim = _get_claim_or_404(session, claim_id) | |
| if claim.state is not ClaimState.ADJUDICATION: | |
| raise HTTPException( | |
| status_code=409, | |
| detail=f"email drafting requires state ADJUDICATION; claim is {claim.state.value}", | |
| ) | |
| claimant = claim.claimant | |
| result = draft_claimant_email( | |
| settings, | |
| session, | |
| claim_id=claim.id, | |
| decision=body.decision, | |
| first_name=_first_name(claimant.full_name), | |
| language=_language(claimant.preferred_language), | |
| tone=_tone(claimant.preferred_tone), | |
| claim_ref=claim.claim_ref, | |
| claim_type=claim.claim_type, | |
| ) | |
| # PII note: the audit payload carries NO draft text (subject/greeting/body can hold | |
| # the claimant's first name) — only the decision and generation provenance. | |
| audit.append( | |
| session, | |
| AuditEventType.EMAIL_DRAFTED, | |
| claim_id=claim.id, | |
| actor_user_id=user.id, | |
| actor_role=user.role.value, | |
| payload={ | |
| "decision": body.decision, | |
| "generated_by": result.generated_by, | |
| "fallback_reason": result.fallback_reason, | |
| }, | |
| ) | |
| session.commit() | |
| payload = result.payload | |
| return DraftEmailOut( | |
| subject=payload["subject"], | |
| greeting=payload["greeting"], | |
| body_paragraphs=payload["body_paragraphs"], | |
| closing=payload["closing"], | |
| generated_by=result.generated_by, | |
| fallback_reason=result.fallback_reason, | |
| ) | |
| def decide_claim( | |
| claim_id: int, | |
| body: DecisionRequest, | |
| user: User = Depends(require_role(Role.INSURANCE_AGENT)), | |
| session: Session = Depends(get_db), | |
| settings: Settings = Depends(get_settings_dep), | |
| ) -> DecisionOut: | |
| """The atomic endpoint: transition + Decision row + audit + notification email are | |
| one transaction with a single commit. Chroma precedent indexing is external to the | |
| DB transaction (best-effort, logged on failure, never blocks the decision).""" | |
| claim = _get_claim_or_404(session, claim_id) | |
| action = ClaimAction.APPROVE if body.action == "approve" else ClaimAction.REJECT | |
| decision_value: Literal["APPROVED", "REJECTED"] = ( | |
| "APPROVED" if action is ClaimAction.APPROVE else "REJECTED" | |
| ) | |
| try: | |
| state_machine.apply_transition(session, claim, action, actor=user, note=body.note) | |
| except TransitionError as exc: | |
| raise HTTPException(status_code=409, detail=exc.reason) from exc | |
| audit.append( | |
| session, | |
| AuditEventType.DECISION_APPROVE | |
| if action is ClaimAction.APPROVE | |
| else AuditEventType.DECISION_REJECT, | |
| claim_id=claim.id, | |
| actor_user_id=user.id, | |
| actor_role=user.role.value, | |
| payload={"note_present": bool(body.note)}, | |
| ) | |
| # The notification email NEVER blocks the decision: empty fields fall back to the | |
| # deterministic template, and send_claim_email never raises on delivery failure. | |
| subject = body.email_subject.strip() | |
| body_text = body.email_body_text.strip() | |
| if not subject or not body_text: | |
| fallback_subject, fallback_body = _fallback_email_text(claim, decision_value) | |
| subject = subject or fallback_subject | |
| body_text = body_text or fallback_body | |
| notification = send_claim_email( | |
| session, | |
| settings, | |
| claim=claim, | |
| recipient=claim.claimant, | |
| subject=subject, | |
| body_text=body_text, | |
| ) | |
| case_ref: str | None = None | |
| try: | |
| report = _latest_artifact(session, DiagnosticReport, claim.id) | |
| note = _latest_artifact(session, RecommendationNote, claim.id) | |
| case_ref = index_closed_case( | |
| settings, | |
| claim_ref=claim.claim_ref, | |
| modality=report.modality if report is not None else None, | |
| claim_type=claim.claim_type, | |
| procedure_code=claim.procedure_code, | |
| diagnosis_code=claim.diagnosis_code, | |
| recommendation=( | |
| note.recommendation.value | |
| if note is not None and note.recommendation is not None | |
| else None | |
| ), | |
| key_findings=_key_findings(note), | |
| decision=decision_value, | |
| ) | |
| except Exception: # noqa: BLE001 — precedent indexing must never fail the decision | |
| logger.exception("precedent indexing failed for claim %s; decision proceeds", claim.id) | |
| session.commit() | |
| return DecisionOut( | |
| state=claim.state.value, | |
| notification_id=notification.id, | |
| notification_status=notification.status.value, | |
| case_ref=case_ref, | |
| ) | |