claimflow-api / tests /test_inference_runner.py
Minifigures's picture
feat: ClaimFlow API demo backend
ceea9e1 verified
Raw
History Blame Contribute Delete
8.53 kB
import asyncio
import json
import time
from itertools import count
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
import app.ml.base as ml_base
from app.claimguard import audit
from app.config import Settings
from app.models import (
ArtifactStatus,
AuditEvent,
Claim,
ClaimState,
Decision,
DiagnosticReport,
Document,
DocumentKind,
Modality,
Role,
User,
)
from app.services import inference_runner
from app.services.inference_runner import recover_orphans, run_stage1
_ref_counter = count(1)
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 256
def write_png(tmp_path: Path, name: str = "knee_xray.png") -> Path:
path = tmp_path / name
path.write_bytes(PNG_BYTES)
return path
def make_claim_chain(
session: Session,
users: dict[str, User],
image_path: Path,
*,
report_status: ArtifactStatus = ArtifactStatus.PENDING,
) -> tuple[Claim, Document, DiagnosticReport]:
"""ORM-only setup: claim (SUBMITTED) + imaging document + diagnostic report."""
claimant = users[Role.CLAIMANT.value]
claim = Claim(
claim_ref=f"CLM-INF-{next(_ref_counter):05d}",
claimant_id=claimant.id,
claim_type="imaging",
state=ClaimState.SUBMITTED,
)
session.add(claim)
session.flush()
document = Document(
claim_id=claim.id,
uploader_id=claimant.id,
kind=DocumentKind.IMAGING,
modality=Modality.XRAY,
filename=image_path.name,
mime="image/png",
size_bytes=image_path.stat().st_size if image_path.exists() else 0,
sha256="0" * 64,
storage_path=str(image_path),
)
session.add(document)
session.flush()
report = DiagnosticReport(claim_id=claim.id, document_id=document.id, status=report_status)
session.add(report)
session.commit()
return claim, document, report
def test_run_stage1_happy_path(
settings: Settings, session: Session, users: dict[str, User], tmp_path: Path
) -> None:
image = write_png(tmp_path)
claim, document, report = make_claim_chain(session, users, image)
asyncio.run(run_stage1(settings, report.id))
session.expire_all()
report = session.get(DiagnosticReport, report.id)
claim = session.get(Claim, claim.id)
assert report is not None and claim is not None
assert report.status == ArtifactStatus.COMPLETE
assert report.error is None
assert report.completed_at is not None
assert report.modality == "xray"
assert report.generated_by == "fallback_template" # keyless stage-1c path
assert report.fallback_reason == "no_api_key"
assert report.prompt_version == "v1"
assert report.authenticity_verdict == "authentic"
# keyless fallback pins confidence to 0.0, which always flags mandatory review
assert report.requires_mandatory_review is True
assert report.payload_json is not None
payload = json.loads(report.payload_json)
assert payload["authenticity"]["verdict"] == "authentic"
assert payload["classifier"]["modality"] == "xray"
assert "disclaimer" in payload
assert document.filename # document row still linked to the report
assert report.document_id == document.id
assert claim.state == ClaimState.IMAGING_REVIEW
llm_events = session.scalars(
select(AuditEvent).where(
AuditEvent.event_type == "llm_call", AuditEvent.claim_id == claim.id
)
).all()
assert len(llm_events) == 1
assert json.loads(llm_events[0].payload_json)["stage"] == "imaging_analysis"
ok, n = audit.verify_chain(session)
assert ok is True
assert n == 2 # llm_call + workflow.transition
def test_run_stage1_missing_file_marks_failed_without_transition(
settings: Settings, session: Session, users: dict[str, User], tmp_path: Path
) -> None:
missing = tmp_path / "never_written.png"
claim, _, report = make_claim_chain(session, users, missing)
asyncio.run(run_stage1(settings, report.id))
session.expire_all()
report = session.get(DiagnosticReport, report.id)
claim = session.get(Claim, claim.id)
assert report is not None and claim is not None
assert report.status == ArtifactStatus.FAILED
assert report.error is not None and "FileNotFoundError" in report.error
assert claim.state == ClaimState.SUBMITTED
assert session.scalars(select(Decision)).all() == []
assert session.scalars(select(AuditEvent)).all() == []
class _SleepyAnalyzer:
def analyze(self, image_path: Path, *, declared_modality: str | None, dicom_meta: dict | None):
time.sleep(1.0)
return None
def test_run_stage1_timeout_marks_failed(
settings: Settings,
session: Session,
users: dict[str, User],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
image = write_png(tmp_path)
claim, _, report = make_claim_chain(session, users, image)
monkeypatch.setattr(inference_runner, "ANALYZE_TIMEOUT_S", 0.01)
# Patch the registry and the runner's imported reference to it.
monkeypatch.setattr(ml_base, "get_analyzer", lambda _settings: _SleepyAnalyzer())
monkeypatch.setattr(inference_runner, "get_analyzer", lambda _settings: _SleepyAnalyzer())
asyncio.run(run_stage1(settings, report.id))
session.expire_all()
report = session.get(DiagnosticReport, report.id)
claim = session.get(Claim, claim.id)
assert report is not None and claim is not None
assert report.status == ArtifactStatus.FAILED
assert report.error is not None and "TimeoutError" in report.error
assert claim.state == ClaimState.SUBMITTED
def test_run_stage1_is_idempotent_on_complete_report(
settings: Settings, session: Session, users: dict[str, User], tmp_path: Path
) -> None:
image = write_png(tmp_path)
claim, _, report = make_claim_chain(session, users, image)
asyncio.run(run_stage1(settings, report.id))
session.expire_all()
report = session.get(DiagnosticReport, report.id)
assert report is not None and report.status == ArtifactStatus.COMPLETE
payload_before = report.payload_json
completed_before = report.completed_at
events_before = len(session.scalars(select(AuditEvent)).all())
decisions_before = len(session.scalars(select(Decision)).all())
asyncio.run(run_stage1(settings, report.id))
session.expire_all()
report = session.get(DiagnosticReport, report.id)
claim = session.get(Claim, claim.id)
assert report is not None and claim is not None
assert report.status == ArtifactStatus.COMPLETE
assert report.payload_json == payload_before
assert report.completed_at == completed_before
assert claim.state == ClaimState.IMAGING_REVIEW
assert len(session.scalars(select(AuditEvent)).all()) == events_before
assert len(session.scalars(select(Decision)).all()) == decisions_before
def test_recover_orphans_fails_running_reports(
app, settings: Settings, session: Session, users: dict[str, User], tmp_path: Path
) -> None:
image = write_png(tmp_path)
_, _, running = make_claim_chain(session, users, image, report_status=ArtifactStatus.RUNNING)
_, _, pending = make_claim_chain(session, users, image)
recovered = recover_orphans(app)
assert recovered == 1
session.expire_all()
running = session.get(DiagnosticReport, running.id)
pending = session.get(DiagnosticReport, pending.id)
assert running is not None and pending is not None
assert running.status == ArtifactStatus.FAILED
assert running.error is not None and "orphaned at startup" in running.error
assert pending.status == ArtifactStatus.PENDING
def test_tampered_filename_demo_hook(
settings: Settings, session: Session, users: dict[str, User], tmp_path: Path
) -> None:
image = write_png(tmp_path, name="tampered_xray.png")
claim, _, report = make_claim_chain(session, users, image)
asyncio.run(run_stage1(settings, report.id))
session.expire_all()
report = session.get(DiagnosticReport, report.id)
claim = session.get(Claim, claim.id)
assert report is not None and claim is not None
assert report.status == ArtifactStatus.COMPLETE
assert report.authenticity_verdict == "likely_fraudulent"
assert report.requires_mandatory_review is True
assert report.payload_json is not None
payload = json.loads(report.payload_json)
assert payload["authenticity"]["verdict"] == "likely_fraudulent"
assert claim.state == ClaimState.IMAGING_REVIEW