diff --git a/apps/dashboard/src/api/__tests__/graphqlService.test.ts b/apps/dashboard/src/api/__tests__/graphqlService.test.ts
index 9c7c767..bb5e73c 100644
--- a/apps/dashboard/src/api/__tests__/graphqlService.test.ts
+++ b/apps/dashboard/src/api/__tests__/graphqlService.test.ts
@@ -14,7 +14,9 @@ import { graphqlClient } from '../graphqlClient';
import {
useApprovePARequest,
useDenyPARequest,
+ useProcessPARequest,
useConnectionStatus,
+ QUERY_KEYS,
} from '../graphqlService';
const mockRequest = vi.mocked(graphqlClient.request);
@@ -75,6 +77,65 @@ describe('useDenyPARequest', () => {
});
});
+describe('useProcessPARequest', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should call processPARequest mutation with correct id', async () => {
+ mockRequest.mockResolvedValueOnce({
+ processPARequest: { id: 'PA-001', status: 'ready' },
+ });
+
+ const { result } = renderHook(() => useProcessPARequest(), {
+ wrapper: createWrapper(),
+ });
+
+ result.current.mutate('PA-001');
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(mockRequest).toHaveBeenCalledOnce();
+ const callArgs = mockRequest.mock.calls[0];
+ expect(callArgs[0]).toContain('processPARequest');
+ expect(callArgs[1]).toEqual({ id: 'PA-001' });
+ });
+
+ it('useProcessPARequest_InvalidatesCorrectQueries_OnSuccess', async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+
+ // Seed the cache with data for each query key we expect to be invalidated
+ queryClient.setQueryData(QUERY_KEYS.paRequests, []);
+ queryClient.setQueryData(QUERY_KEYS.paRequest('PA-002'), { id: 'PA-002' });
+ queryClient.setQueryData(QUERY_KEYS.paStats, { ready: 0 });
+ queryClient.setQueryData(QUERY_KEYS.activity, []);
+
+ const wrapper = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(QueryClientProvider, { client: queryClient }, children);
+
+ mockRequest.mockResolvedValueOnce({
+ processPARequest: { id: 'PA-002', status: 'ready' },
+ });
+
+ const { result } = renderHook(() => useProcessPARequest(), { wrapper });
+
+ // Spy on invalidateQueries
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+
+ result.current.mutate('PA-002');
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // Verify all four query keys are invalidated
+ const invalidatedKeys = invalidateSpy.mock.calls.map(call => call[0]?.queryKey);
+ expect(invalidatedKeys).toContainEqual(QUERY_KEYS.paRequests);
+ expect(invalidatedKeys).toContainEqual(QUERY_KEYS.paRequest('PA-002'));
+ expect(invalidatedKeys).toContainEqual(QUERY_KEYS.paStats);
+ expect(invalidatedKeys).toContainEqual(QUERY_KEYS.activity);
+ });
+});
+
describe('useConnectionStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/apps/dashboard/src/components/__tests__/EvidencePanel.test.tsx b/apps/dashboard/src/components/__tests__/EvidencePanel.test.tsx
index 997202a..1d1bcb8 100644
--- a/apps/dashboard/src/components/__tests__/EvidencePanel.test.tsx
+++ b/apps/dashboard/src/components/__tests__/EvidencePanel.test.tsx
@@ -117,4 +117,114 @@ describe('EvidencePanel', () => {
expect(screen.getByTestId('evidence-skeleton')).toBeInTheDocument();
});
});
+
+ describe('Intelligence-shaped evidence', () => {
+ it('EvidencePanel_WithIntelligenceEvidence_DisplaysCriterionIdAsLabel', () => {
+ const intelligenceEvidence: EvidenceItem[] = [
+ {
+ criterionId: 'conservative_therapy',
+ status: 'MET',
+ evidence: 'Patient completed 8 weeks of physical therapy and NSAID treatment',
+ source: 'Clinical Notes - 2026-01-15',
+ confidence: 0.95,
+ },
+ {
+ criterionId: 'medical_necessity',
+ status: 'MET',
+ evidence: 'MRI indicated for evaluation of persistent symptoms',
+ source: 'Order Entry - 2026-01-20',
+ confidence: 0.88,
+ },
+ ];
+ render();
+
+ expect(screen.getByText('Conservative Therapy')).toBeInTheDocument();
+ expect(screen.getByText('Medical Necessity')).toBeInTheDocument();
+ });
+
+ it('EvidencePanel_WithUnclearStatus_ShowsDistinctBadge', () => {
+ const evidence: EvidenceItem[] = [
+ {
+ criterionId: 'diagnosis_present',
+ status: 'UNCLEAR',
+ evidence: 'Diagnosis code needs verification',
+ source: 'System',
+ confidence: 0.5,
+ },
+ ];
+ render();
+
+ const badge = screen.getByText('Unclear');
+ expect(badge).toBeInTheDocument();
+ // UNCLEAR uses warning styling
+ expect(badge.className).toMatch(/bg-warning/);
+ expect(badge.className).toMatch(/text-warning/);
+ // Should NOT use MET (success) or NOT_MET (destructive) styling
+ expect(badge.className).not.toMatch(/bg-success/);
+ expect(badge.className).not.toMatch(/text-success/);
+ expect(badge.className).not.toMatch(/bg-destructive/);
+ expect(badge.className).not.toMatch(/text-destructive/);
+ });
+
+ it('EvidencePanel_WithAllThreeStatuses_ShowsCorrectSummaryBreakdown', () => {
+ const mixedEvidence: EvidenceItem[] = [
+ {
+ criterionId: 'conservative_therapy',
+ status: 'MET',
+ evidence: 'PT completed',
+ source: 'Notes',
+ confidence: 0.95,
+ },
+ {
+ criterionId: 'diagnosis_present',
+ status: 'UNCLEAR',
+ evidence: 'Needs verification',
+ source: 'System',
+ confidence: 0.5,
+ },
+ {
+ criterionId: 'imaging_prior',
+ status: 'NOT_MET',
+ evidence: 'No prior imaging found',
+ source: 'Radiology',
+ confidence: 0.9,
+ },
+ ];
+ render();
+
+ expect(screen.getByText('3 criteria analyzed')).toBeInTheDocument();
+ });
+
+ it('EvidencePanel_WithLowConfidence_ShowsWarningColor', () => {
+ const evidence: EvidenceItem[] = [
+ {
+ criterionId: 'medical_necessity',
+ status: 'MET',
+ evidence: 'Some evidence found',
+ source: 'Notes',
+ confidence: 0.35,
+ },
+ ];
+ render();
+
+ // 35% confidence should show destructive color (below 0.5 threshold)
+ expect(screen.getByText(/35%/)).toBeInTheDocument();
+ });
+
+ it('EvidencePanel_WithBorderlineConfidence_ShowsCorrectColor', () => {
+ const evidence: EvidenceItem[] = [
+ {
+ criterionId: 'conservative_therapy',
+ status: 'MET',
+ evidence: 'Partial documentation',
+ source: 'Notes',
+ confidence: 0.5,
+ },
+ ];
+ render();
+
+ // 50% is at the boundary — should show warning color
+ expect(screen.getByText(/50%/)).toBeInTheDocument();
+ });
+ });
});
diff --git a/apps/dashboard/src/components/__tests__/PARequestCard.test.tsx b/apps/dashboard/src/components/__tests__/PARequestCard.test.tsx
new file mode 100644
index 0000000..b329d23
--- /dev/null
+++ b/apps/dashboard/src/components/__tests__/PARequestCard.test.tsx
@@ -0,0 +1,148 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { PARequestCard, type PARequest } from '../PARequestCard';
+
+// Mock @tanstack/react-router Link component
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({ children, to, params }: { children: React.ReactNode; to: string; params?: Record }) => (
+
+ {children}
+
+ ),
+}));
+
+function createMockPARequest(overrides: Partial = {}): PARequest {
+ return {
+ id: 'PA-001',
+ patientName: 'Jane Smith',
+ patientId: 'MRN-12345',
+ procedureCode: '72148',
+ procedureName: 'MRI Lumbar Spine',
+ payer: 'Blue Cross Blue Shield',
+ currentStep: 'process',
+ createdAt: new Date().toISOString(),
+ encounterId: 'ENC-001',
+ ...overrides,
+ };
+}
+
+describe('PARequestCard', () => {
+ describe('rendering', () => {
+ it('PARequestCard_WithValidRequest_DisplaysPatientInfo', () => {
+ const request = createMockPARequest();
+ render();
+
+ expect(screen.getByText('Jane Smith')).toBeInTheDocument();
+ expect(screen.getByText(/MRN-12345/)).toBeInTheDocument();
+ });
+
+ it('PARequestCard_WithValidRequest_DisplaysProcedure', () => {
+ const request = createMockPARequest();
+ render();
+
+ expect(screen.getByText('72148')).toBeInTheDocument();
+ expect(screen.getByText('MRI Lumbar Spine')).toBeInTheDocument();
+ });
+
+ it('PARequestCard_WithValidRequest_DisplaysPayer', () => {
+ const request = createMockPARequest();
+ render();
+
+ expect(screen.getByText('Blue Cross Blue Shield')).toBeInTheDocument();
+ });
+ });
+
+ describe('confidence display', () => {
+ it('PARequestCard_WithHighConfidence_DisplaysPercentage', () => {
+ const request = createMockPARequest({ confidenceScore: 0.92 });
+ render();
+
+ expect(screen.getByText(/92%/)).toBeInTheDocument();
+ });
+
+ it('PARequestCard_WithMediumConfidence_DisplaysPercentage', () => {
+ const request = createMockPARequest({ confidenceScore: 0.65 });
+ render();
+
+ expect(screen.getByText(/65%/)).toBeInTheDocument();
+ });
+
+ it('PARequestCard_WithLowConfidence_DisplaysReviewBadge', () => {
+ const request = createMockPARequest({ confidenceScore: 0.42 });
+ render();
+
+ // Low confidence (< 0.5) shows "Review" text in a badge (data-slot="badge")
+ const badges = screen.getAllByText('Review');
+ const confidenceBadge = badges.find(el => el.getAttribute('data-slot') === 'badge');
+ expect(confidenceBadge).toBeDefined();
+ });
+
+ it('PARequestCard_WithUndefinedConfidence_ShowsNoConfidenceBadge', () => {
+ const request = createMockPARequest({ confidenceScore: undefined });
+ render();
+
+ // No confidence percentage badge should render
+ expect(screen.queryByText(/\d+%/)).not.toBeInTheDocument();
+ // The only "Review" text should be from the WorkflowProgress step, not a badge
+ const reviewElements = screen.queryAllByText('Review');
+ const confidenceBadge = reviewElements.find(el => el.getAttribute('data-slot') === 'badge');
+ expect(confidenceBadge).toBeUndefined();
+ });
+
+ it('PARequestCard_WithRealConfidenceRange_DisplaysCorrectly', () => {
+ // Test with various realistic confidence values from Intelligence
+ const request = createMockPARequest({ confidenceScore: 0.78 });
+ render();
+
+ expect(screen.getByText(/78%/)).toBeInTheDocument();
+ });
+ });
+
+ describe('workflow states', () => {
+ it('PARequestCard_InProcessingState_ShowsProcessingIndicator', () => {
+ const request = createMockPARequest({ currentStep: 'process' });
+ render();
+
+ expect(screen.getByText('Processing...')).toBeInTheDocument();
+ });
+
+ it('PARequestCard_InDeliverState_ShowsReviewButton', () => {
+ const request = createMockPARequest({ currentStep: 'deliver' });
+ render();
+
+ expect(screen.getByText('Review & Confirm')).toBeInTheDocument();
+ });
+
+ it('PARequestCard_InReviewCompletedState_ShowsSubmittedMessage', () => {
+ const request = createMockPARequest({
+ currentStep: 'review',
+ stepStatuses: { review: 'completed' },
+ });
+ render();
+
+ expect(screen.getByText('Submitted to athenahealth')).toBeInTheDocument();
+ });
+ });
+
+ describe('attention flag', () => {
+ it('PARequestCard_RequiresAttention_HasWarningRing', () => {
+ const request = createMockPARequest({ requiresAttention: true });
+ const { container } = render();
+
+ const card = container.firstChild as HTMLElement;
+ expect(card.className).toMatch(/ring-warning/);
+ });
+ });
+
+ describe('callbacks', () => {
+ it('PARequestCard_OnReviewClick_CallsCallback', () => {
+ const onReview = vi.fn();
+ const request = createMockPARequest({ currentStep: 'deliver' });
+ render();
+
+ fireEvent.click(screen.getByText('Review & Confirm'));
+
+ expect(onReview).toHaveBeenCalledWith('PA-001');
+ });
+ });
+});
diff --git a/apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs b/apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs
index ccd801b..afa71ab 100644
--- a/apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs
+++ b/apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs
@@ -6,9 +6,11 @@
namespace Gateway.API.Tests;
+using Gateway.API.Configuration;
using Gateway.API.Contracts;
using Gateway.API.Data;
using Gateway.API.Services;
+using Gateway.API.Services.Decorators;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -79,6 +81,52 @@ public async Task AddGatewayPersistence_RegistersPostgresStores()
await Assert.That(patientRegistry).IsTypeOf();
}
+ [Test]
+ public async Task AddIntelligenceClient_RegistersHttpClientForIntelligenceClient()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ services.AddLogging();
+ var config = CreateTestConfiguration();
+ services.AddSingleton(config);
+ services.AddHybridCache();
+ services.AddOptions()
+ .BindConfiguration(CachingSettings.SectionName);
+
+ // Act
+ services.AddIntelligenceClient();
+ var provider = services.BuildServiceProvider();
+
+ using var scope = provider.CreateScope();
+ var client = scope.ServiceProvider.GetService();
+
+ // Assert
+ await Assert.That(client).IsNotNull();
+ }
+
+ [Test]
+ public async Task AddIntelligenceClient_AppliesCachingDecorator()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ services.AddLogging();
+ var config = CreateTestConfiguration();
+ services.AddSingleton(config);
+ services.AddHybridCache();
+ services.AddOptions()
+ .BindConfiguration(CachingSettings.SectionName);
+
+ // Act
+ services.AddIntelligenceClient();
+ var provider = services.BuildServiceProvider();
+
+ using var scope = provider.CreateScope();
+ var client = scope.ServiceProvider.GetService();
+
+ // Assert - Resolved service should be the caching decorator
+ await Assert.That(client).IsTypeOf();
+ }
+
private static IConfiguration CreateTestConfiguration()
{
var configValues = new Dictionary
@@ -88,7 +136,9 @@ private static IConfiguration CreateTestConfiguration()
["Document:MaxSizeMb"] = "10",
["Caching:Enabled"] = "false",
["Caching:Duration"] = "00:05:00",
- ["Caching:LocalCacheDuration"] = "00:01:00"
+ ["Caching:LocalCacheDuration"] = "00:01:00",
+ ["Intelligence:BaseUrl"] = "http://localhost:8000",
+ ["Intelligence:TimeoutSeconds"] = "30"
};
return new ConfigurationBuilder()
.AddInMemoryCollection(configValues)
diff --git a/apps/gateway/Gateway.API.Tests/GraphQL/ProcessPARequestMutationTests.cs b/apps/gateway/Gateway.API.Tests/GraphQL/ProcessPARequestMutationTests.cs
new file mode 100644
index 0000000..d75a6c1
--- /dev/null
+++ b/apps/gateway/Gateway.API.Tests/GraphQL/ProcessPARequestMutationTests.cs
@@ -0,0 +1,219 @@
+using Gateway.API.Contracts;
+using Gateway.API.GraphQL.Models;
+using Gateway.API.GraphQL.Mutations;
+using Gateway.API.Models;
+using Gateway.API.Services;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+
+namespace Gateway.API.Tests.GraphQL;
+
+[Category("Unit")]
+public sealed class ProcessPARequestMutationTests
+{
+ private readonly MockDataService _mockData = new();
+ private readonly IFhirDataAggregator _fhirAggregator = Substitute.For();
+ private readonly IIntelligenceClient _intelligenceClient = Substitute.For();
+ private readonly Mutation _sut = new();
+
+ private static ClinicalBundle CreateTestBundle(string patientId = "60178") => new()
+ {
+ PatientId = patientId,
+ Patient = new PatientInfo
+ {
+ Id = patientId,
+ GivenName = "Donna",
+ FamilyName = "Sandbox",
+ BirthDate = new DateOnly(1968, 3, 15),
+ MemberId = "ATH60178"
+ },
+ Conditions = [new ConditionInfo { Id = "cond-1", Code = "M54.5", Display = "Low Back Pain", ClinicalStatus = "active" }],
+ };
+
+ private static PAFormData CreateTestFormData(
+ string recommendation = "APPROVE",
+ double confidence = 0.85,
+ string procedureCode = "72148") => new()
+ {
+ PatientName = "Donna Sandbox",
+ PatientDob = "1968-03-15",
+ MemberId = "ATH60178",
+ DiagnosisCodes = ["M54.5"],
+ ProcedureCode = procedureCode,
+ ClinicalSummary = "AI-generated clinical summary for testing.",
+ SupportingEvidence =
+ [
+ new EvidenceItem
+ {
+ CriterionId = "conservative_therapy",
+ Status = "MET",
+ Evidence = "Patient completed 8 weeks of PT",
+ Source = "Clinical Notes",
+ Confidence = 0.95
+ },
+ new EvidenceItem
+ {
+ CriterionId = "failed_treatment",
+ Status = "NOT_MET",
+ Evidence = "No documentation of treatment failure",
+ Source = "Chart Review",
+ Confidence = 0.70
+ },
+ new EvidenceItem
+ {
+ CriterionId = "diagnosis_present",
+ Status = "UNCLEAR",
+ Evidence = "Diagnosis code needs verification",
+ Source = "System",
+ Confidence = 0.50
+ }
+ ],
+ Recommendation = recommendation,
+ ConfidenceScore = confidence,
+ FieldMappings = new Dictionary { ["PatientName"] = "Donna Sandbox" }
+ };
+
+ [Test]
+ public async Task ProcessPARequest_CallsFhirAggregator_WithPatientId()
+ {
+ var paRequest = _mockData.GetPARequests().First();
+ _fhirAggregator.AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(CreateTestBundle(paRequest.PatientId));
+ _intelligenceClient.AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(CreateTestFormData());
+
+ await _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None);
+
+ await _fhirAggregator.Received(1).AggregateClinicalDataAsync(
+ paRequest.PatientId, Arg.Any(), Arg.Any());
+ }
+
+ [Test]
+ public async Task ProcessPARequest_CallsIntelligenceClient_WithBundleAndProcedureCode()
+ {
+ var paRequest = _mockData.GetPARequests().First();
+ var bundle = CreateTestBundle(paRequest.PatientId);
+ _fhirAggregator.AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(bundle);
+ _intelligenceClient.AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(CreateTestFormData(procedureCode: paRequest.ProcedureCode));
+
+ await _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None);
+
+ await _intelligenceClient.Received(1).AnalyzeAsync(
+ Arg.Is(b => b.PatientId == paRequest.PatientId),
+ paRequest.ProcedureCode,
+ Arg.Any());
+ }
+
+ [Test]
+ public async Task ProcessPARequest_MapsEvidenceToMet_WhenStatusIsMET()
+ {
+ var paRequest = _mockData.GetPARequests().First();
+ SetupMocks(paRequest.PatientId, paRequest.ProcedureCode);
+
+ var result = await _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None);
+
+ await Assert.That(result).IsNotNull();
+ var metCriterion = result!.Criteria.First(c => c.Label == "conservative_therapy");
+ await Assert.That(metCriterion.Met).IsTrue();
+ }
+
+ [Test]
+ public async Task ProcessPARequest_MapsEvidenceToNotMet_WhenStatusIsNOT_MET()
+ {
+ var paRequest = _mockData.GetPARequests().First();
+ SetupMocks(paRequest.PatientId, paRequest.ProcedureCode);
+
+ var result = await _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None);
+
+ await Assert.That(result).IsNotNull();
+ var notMetCriterion = result!.Criteria.First(c => c.Label == "failed_treatment");
+ await Assert.That(notMetCriterion.Met).IsFalse();
+ }
+
+ [Test]
+ public async Task ProcessPARequest_MapsEvidenceToNull_WhenStatusIsUNCLEAR()
+ {
+ var paRequest = _mockData.GetPARequests().First();
+ SetupMocks(paRequest.PatientId, paRequest.ProcedureCode);
+
+ var result = await _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None);
+
+ await Assert.That(result).IsNotNull();
+ var unclearCriterion = result!.Criteria.First(c => c.Label == "diagnosis_present");
+ await Assert.That(unclearCriterion.Met).IsNull();
+ }
+
+ [Test]
+ public async Task ProcessPARequest_SetsConfidence_FromScoreTimes100()
+ {
+ var paRequest = _mockData.GetPARequests().First();
+ SetupMocks(paRequest.PatientId, paRequest.ProcedureCode, confidence: 0.85);
+
+ var result = await _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None);
+
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result!.Confidence).IsEqualTo(85);
+ }
+
+ [Test]
+ public async Task ProcessPARequest_ReturnsNull_WhenPARequestNotFound()
+ {
+ var result = await _sut.ProcessPARequest("NONEXISTENT", _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None);
+
+ await Assert.That(result).IsNull();
+ // Should NOT call any services
+ await _fhirAggregator.DidNotReceive().AggregateClinicalDataAsync(
+ Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Test]
+ public async Task ProcessPARequest_WhenFhirAggregatorThrows_PropagatesException()
+ {
+ var paRequest = _mockData.GetPARequests().First();
+ _fhirAggregator.AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .ThrowsAsync(new HttpRequestException("FHIR service unavailable"));
+
+ var act = () => _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None);
+
+ await Assert.That(act).ThrowsExactly();
+ }
+
+ [Test]
+ public async Task ProcessPARequest_WhenIntelligenceThrowsHttpRequestException_PropagatesException()
+ {
+ var paRequest = _mockData.GetPARequests().First();
+ _fhirAggregator.AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(CreateTestBundle(paRequest.PatientId));
+ _intelligenceClient.AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .ThrowsAsync(new HttpRequestException("Intelligence service returned 500"));
+
+ var act = () => _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, CancellationToken.None);
+
+ await Assert.That(act).ThrowsExactly();
+ }
+
+ [Test]
+ public async Task ProcessPARequest_WhenCancelled_ThrowsOperationCanceledException()
+ {
+ var paRequest = _mockData.GetPARequests().First();
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ _fhirAggregator.AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .ThrowsAsync(new OperationCanceledException());
+
+ var act = () => _sut.ProcessPARequest(paRequest.Id, _mockData, _fhirAggregator, _intelligenceClient, cts.Token);
+
+ await Assert.That(act).ThrowsExactly();
+ }
+
+ private void SetupMocks(string patientId, string procedureCode, double confidence = 0.85)
+ {
+ _fhirAggregator.AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(CreateTestBundle(patientId));
+ _intelligenceClient.AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(CreateTestFormData(confidence: confidence, procedureCode: procedureCode));
+ }
+}
diff --git a/apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs b/apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs
index 44300c3..8120adc 100644
--- a/apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs
+++ b/apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs
@@ -124,7 +124,39 @@ public async Task InitializeAsync()
services.RemoveAll();
services.AddSingleton(mockAggregator);
- // IntelligenceClient is already a stub - no replacement needed
+ // Replace IntelligenceClient with mock returning test PA form data
+ var mockIntelligenceClient = Substitute.For();
+ mockIntelligenceClient
+ .AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(callInfo => Task.FromResult(new PAFormData
+ {
+ PatientName = "Test Patient",
+ PatientDob = "1990-01-01",
+ MemberId = "MEM-TEST",
+ DiagnosisCodes = ["M54.5"],
+ ProcedureCode = callInfo.ArgAt(1),
+ ClinicalSummary = "Test clinical summary",
+ SupportingEvidence =
+ [
+ new EvidenceItem
+ {
+ CriterionId = "diagnosis_present",
+ Status = "MET",
+ Evidence = "Test evidence",
+ Source = "Test",
+ Confidence = 0.95
+ }
+ ],
+ Recommendation = "APPROVE",
+ ConfidenceScore = 0.95,
+ FieldMappings = new Dictionary
+ {
+ ["PatientName"] = "Test Patient"
+ }
+ }));
+ services.RemoveAll();
+ services.AddSingleton(mockIntelligenceClient);
+
// PdfFormStamper is already a stub - no replacement needed
// Remove the Aspire Redis registration that would fail without a real connection
diff --git a/apps/gateway/Gateway.API.Tests/Integration/GatewayAlbaBootstrap.cs b/apps/gateway/Gateway.API.Tests/Integration/GatewayAlbaBootstrap.cs
index a441532..017d57e 100644
--- a/apps/gateway/Gateway.API.Tests/Integration/GatewayAlbaBootstrap.cs
+++ b/apps/gateway/Gateway.API.Tests/Integration/GatewayAlbaBootstrap.cs
@@ -123,7 +123,38 @@ public async Task InitializeAsync()
services.RemoveAll();
services.AddSingleton(mockAggregator);
- // IntelligenceClient is already a stub - no replacement needed
+ // Replace IntelligenceClient with mock returning test PA form data
+ var mockIntelligenceClient = Substitute.For();
+ mockIntelligenceClient
+ .AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(callInfo => Task.FromResult(new PAFormData
+ {
+ PatientName = "Test Patient",
+ PatientDob = "1990-01-01",
+ MemberId = "MEM-TEST",
+ DiagnosisCodes = ["M54.5"],
+ ProcedureCode = callInfo.ArgAt(1),
+ ClinicalSummary = "Test clinical summary",
+ SupportingEvidence =
+ [
+ new EvidenceItem
+ {
+ CriterionId = "diagnosis_present",
+ Status = "MET",
+ Evidence = "Test evidence",
+ Source = "Test",
+ Confidence = 0.95
+ }
+ ],
+ Recommendation = "APPROVE",
+ ConfidenceScore = 0.95,
+ FieldMappings = new Dictionary
+ {
+ ["PatientName"] = "Test Patient"
+ }
+ }));
+ services.RemoveAll();
+ services.AddSingleton(mockIntelligenceClient);
});
}).ConfigureAwait(false);
}
diff --git a/apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestAlbaBootstrap.cs b/apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestAlbaBootstrap.cs
new file mode 100644
index 0000000..ebe386b
--- /dev/null
+++ b/apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestAlbaBootstrap.cs
@@ -0,0 +1,233 @@
+// =============================================================================
+//
+// Copyright (c) Levelup Software. All rights reserved.
+//
+// =============================================================================
+
+using Alba;
+using Gateway.API.Contracts;
+using Gateway.API.Data;
+using Gateway.API.Models;
+using Gateway.API.Services.Polling;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+using NSubstitute;
+using StackExchange.Redis;
+using TUnit.Core.Interfaces;
+
+namespace Gateway.API.Tests.Integration;
+
+///
+/// Alba bootstrap for ProcessPARequest integration tests.
+/// Provides mocked FHIR aggregator and Intelligence client to exercise the full
+/// GraphQL mutation through the ASP.NET pipeline.
+///
+public sealed class ProcessPARequestAlbaBootstrap : IAsyncInitializer, IAsyncDisposable
+{
+ ///
+ /// Test API key for integration tests.
+ ///
+ public const string TestApiKey = "test-api-key";
+
+ ///
+ /// Gets the Alba host for making HTTP requests.
+ ///
+ public IAlbaHost Host { get; private set; } = null!;
+
+ ///
+ public async Task InitializeAsync()
+ {
+ Host = await AlbaHost.For(config =>
+ {
+ config.ConfigureAppConfiguration((context, configBuilder) =>
+ {
+ configBuilder.AddInMemoryCollection(new Dictionary
+ {
+ ["ApiKey:ValidApiKeys:0"] = TestApiKey,
+ ["Athena:ClientId"] = "test-client-id",
+ ["Athena:ClientSecret"] = "test-client-secret",
+ ["Athena:FhirBaseUrl"] = "https://api.test.athenahealth.com/fhir/r4",
+ ["Athena:TokenEndpoint"] = "https://api.test.athenahealth.com/oauth2/v1/token",
+ ["Athena:PollingIntervalSeconds"] = "30",
+ ["Intelligence:BaseUrl"] = "http://localhost:8000",
+ ["Intelligence:TimeoutSeconds"] = "30",
+ ["ClinicalQuery:ObservationLookbackMonths"] = "12",
+ ["ClinicalQuery:ProcedureLookbackMonths"] = "24",
+ ["ConnectionStrings:authscript"] = "Host=localhost;Database=test;Username=test;Password=test"
+ });
+ });
+
+ config.ConfigureServices(services =>
+ {
+ // Replace Aspire's DbContext with in-memory provider
+ var dbContextDescriptors = services.Where(d =>
+ d.ServiceType == typeof(GatewayDbContext) ||
+ d.ServiceType == typeof(DbContextOptions) ||
+ d.ServiceType == typeof(DbContextOptions) ||
+ d.ServiceType.FullName?.Contains("GatewayDbContext") == true ||
+ d.ImplementationType?.FullName?.Contains("GatewayDbContext") == true).ToList();
+ foreach (var descriptor in dbContextDescriptors)
+ {
+ services.Remove(descriptor);
+ }
+
+ services.RemoveAll(typeof(Microsoft.EntityFrameworkCore.Internal.IDbContextPool));
+ services.RemoveAll(typeof(Microsoft.EntityFrameworkCore.Internal.IScopedDbContextLease));
+
+ var databaseName = $"GatewayTest_{Guid.NewGuid()}";
+ services.AddDbContext(options =>
+ options.UseInMemoryDatabase(databaseName));
+
+ // Remove AthenaPollingService
+ services.RemoveAll();
+ var hostedServiceDescriptor = services.FirstOrDefault(d =>
+ d.ServiceType == typeof(IHostedService) &&
+ d.ImplementationFactory?.Method.ReturnType == typeof(AthenaPollingService));
+ if (hostedServiceDescriptor != null)
+ {
+ services.Remove(hostedServiceDescriptor);
+ }
+
+ // Mock IFhirTokenProvider
+ var mockTokenProvider = Substitute.For();
+ mockTokenProvider
+ .GetTokenAsync(Arg.Any())
+ .Returns(Task.FromResult("test-access-token"));
+ services.RemoveAll();
+ services.AddSingleton(mockTokenProvider);
+
+ // Mock IFhirClient
+ services.RemoveAll();
+ services.AddSingleton(Substitute.For());
+
+ // Mock IFhirDataAggregator with clinical data for patient "60178" (Donna Sandbox)
+ var mockAggregator = Substitute.For();
+ mockAggregator
+ .AggregateClinicalDataAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(CreateTestClinicalBundle()));
+ services.RemoveAll();
+ services.AddSingleton(mockAggregator);
+
+ // Mock IIntelligenceClient with realistic PA form data
+ var mockIntelligenceClient = Substitute.For();
+ mockIntelligenceClient
+ .AnalyzeAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(CreateTestPAFormData()));
+ services.RemoveAll();
+ services.AddSingleton(mockIntelligenceClient);
+
+ // Remove Redis (not available in tests)
+ services.RemoveAll();
+ services.AddSingleton(sp => null);
+ });
+ }).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (Host != null)
+ {
+ await Host.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ private static ClinicalBundle CreateTestClinicalBundle()
+ {
+ return new ClinicalBundle
+ {
+ PatientId = "60178",
+ Patient = new PatientInfo
+ {
+ Id = "60178",
+ GivenName = "Donna",
+ FamilyName = "Sandbox",
+ BirthDate = new DateOnly(1968, 3, 15),
+ Gender = "female",
+ MemberId = "ATH60178"
+ },
+ Conditions =
+ [
+ new ConditionInfo
+ {
+ Id = "cond-001",
+ Code = "M54.5",
+ Display = "Low back pain",
+ ClinicalStatus = "active",
+ OnsetDate = DateOnly.FromDateTime(DateTime.UtcNow.AddMonths(-6))
+ }
+ ],
+ Observations =
+ [
+ new ObservationInfo
+ {
+ Id = "obs-001",
+ Code = "72166-2",
+ Display = "Smoking status",
+ Value = "Never smoker"
+ }
+ ],
+ Procedures =
+ [
+ new ProcedureInfo
+ {
+ Id = "proc-001",
+ Code = "97110",
+ Display = "Therapeutic exercises",
+ PerformedDate = DateOnly.FromDateTime(DateTime.UtcNow.AddMonths(-2))
+ }
+ ],
+ Documents = [],
+ ServiceRequests = []
+ };
+ }
+
+ private static PAFormData CreateTestPAFormData() => new()
+ {
+ PatientName = "Donna Sandbox",
+ PatientDob = "1968-03-15",
+ MemberId = "ATH60178",
+ DiagnosisCodes = ["M54.5"],
+ ProcedureCode = "72148",
+ ClinicalSummary = "Patient presents with chronic low back pain. Conservative therapy including 8 weeks of physical therapy and NSAID therapy has been attempted without adequate relief. MRI lumbar spine is medically indicated.",
+ SupportingEvidence =
+ [
+ new EvidenceItem
+ {
+ CriterionId = "conservative_therapy",
+ Status = "MET",
+ Evidence = "8 weeks of physical therapy and NSAID therapy documented",
+ Source = "Clinical Notes",
+ Confidence = 0.95
+ },
+ new EvidenceItem
+ {
+ CriterionId = "failed_treatment",
+ Status = "MET",
+ Evidence = "Persistent pain rated 7/10 despite conservative therapy",
+ Source = "Progress Notes",
+ Confidence = 0.90
+ },
+ new EvidenceItem
+ {
+ CriterionId = "diagnosis_present",
+ Status = "MET",
+ Evidence = "Valid ICD-10 code M54.5 (Low Back Pain) documented",
+ Source = "Problem List",
+ Confidence = 0.99
+ }
+ ],
+ Recommendation = "APPROVE",
+ ConfidenceScore = 0.92,
+ FieldMappings = new Dictionary
+ {
+ ["PatientName"] = "Donna Sandbox",
+ ["PatientDOB"] = "1968-03-15",
+ ["MemberID"] = "ATH60178",
+ ["ProcedureCode"] = "72148"
+ }
+ };
+}
diff --git a/apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestIntegrationTests.cs b/apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestIntegrationTests.cs
new file mode 100644
index 0000000..6e9687b
--- /dev/null
+++ b/apps/gateway/Gateway.API.Tests/Integration/ProcessPARequestIntegrationTests.cs
@@ -0,0 +1,173 @@
+// =============================================================================
+//
+// Copyright (c) Levelup Software. All rights reserved.
+//
+// =============================================================================
+
+using System.Text.Json;
+using Alba;
+
+namespace Gateway.API.Tests.Integration;
+
+///
+/// Integration tests for the ProcessPARequest GraphQL mutation.
+/// Exercises the full mutation through the ASP.NET pipeline with mocked
+/// FHIR and Intelligence external dependencies.
+///
+[Category("Integration")]
+[ClassDataSource(Shared = SharedType.PerTestSession)]
+public sealed class ProcessPARequestIntegrationTests
+{
+ private readonly ProcessPARequestAlbaBootstrap _fixture;
+
+ public ProcessPARequestIntegrationTests(ProcessPARequestAlbaBootstrap fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [Test]
+ public async Task ProcessPARequest_ViaGraphQL_ReturnsReadyStatusWithAnalysis()
+ {
+ // Arrange - Get a pre-seeded PA request ID (PA-001 uses patient 60178, procedure 72148)
+ var listResult = await _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new
+ {
+ query = "{ paRequests { id status patientId procedureCode } }"
+ }).ToUrl("/api/graphql");
+ s.StatusCodeShouldBe(200);
+ }).ConfigureAwait(false);
+
+ var listBody = listResult.ReadAsText();
+ using var listDoc = JsonDocument.Parse(listBody);
+ var requests = listDoc.RootElement.GetProperty("data").GetProperty("paRequests");
+
+ // Find PA-001 (Donna Sandbox, procedure 72148, status "ready")
+ string paRequestId = "PA-001";
+ var found = false;
+ foreach (var req in requests.EnumerateArray())
+ {
+ if (req.GetProperty("id").GetString() == paRequestId)
+ {
+ found = true;
+ break;
+ }
+ }
+
+ await Assert.That(found).IsTrue();
+
+ // Act - Process the PA request via GraphQL mutation
+ var processResult = await _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new
+ {
+ query = @"mutation($id: String!) {
+ processPARequest(id: $id) {
+ id
+ status
+ confidence
+ clinicalSummary
+ criteria { met label reason }
+ }
+ }",
+ variables = new { id = paRequestId }
+ }).ToUrl("/api/graphql");
+ s.StatusCodeShouldBe(200);
+ }).ConfigureAwait(false);
+
+ // Assert
+ var processBody = processResult.ReadAsText();
+ using var processDoc = JsonDocument.Parse(processBody);
+ var data = processDoc.RootElement.GetProperty("data").GetProperty("processPARequest");
+
+ // ID should match
+ var returnedId = data.GetProperty("id").GetString();
+ await Assert.That(returnedId).IsEqualTo(paRequestId);
+
+ // Status should be "ready" after processing
+ var status = data.GetProperty("status").GetString();
+ await Assert.That(status).IsEqualTo("ready");
+
+ // Confidence should be 92 (0.92 * 100)
+ var confidence = data.GetProperty("confidence").GetInt32();
+ await Assert.That(confidence).IsEqualTo(92);
+
+ // Clinical summary should come from the mock Intelligence response (not contain "STUB")
+ var clinicalSummary = data.GetProperty("clinicalSummary").GetString();
+ await Assert.That(clinicalSummary).IsNotNull();
+ await Assert.That(clinicalSummary!).DoesNotContain("STUB");
+ await Assert.That(clinicalSummary).Contains("chronic low back pain");
+
+ // Criteria should have 3 items from the mock evidence
+ var criteria = data.GetProperty("criteria");
+ await Assert.That(criteria.GetArrayLength()).IsEqualTo(3);
+
+ // Verify first criterion (conservative_therapy -> MET)
+ var firstCriterion = criteria[0];
+ await Assert.That(firstCriterion.GetProperty("met").GetBoolean()).IsTrue();
+ await Assert.That(firstCriterion.GetProperty("label").GetString()).IsEqualTo("conservative_therapy");
+ await Assert.That(firstCriterion.GetProperty("reason").GetString()).Contains("physical therapy");
+ }
+
+ [Test]
+ public async Task ProcessPARequest_WithInvalidId_ReturnsNull()
+ {
+ // Act
+ var result = await _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new
+ {
+ query = @"mutation($id: String!) {
+ processPARequest(id: $id) {
+ id
+ status
+ }
+ }",
+ variables = new { id = "NONEXISTENT-ID" }
+ }).ToUrl("/api/graphql");
+ s.StatusCodeShouldBe(200);
+ }).ConfigureAwait(false);
+
+ // Assert - processPARequest should return null for non-existent ID
+ var body = result.ReadAsText();
+ using var doc = JsonDocument.Parse(body);
+ var data = doc.RootElement.GetProperty("data").GetProperty("processPARequest");
+ await Assert.That(data.ValueKind).IsEqualTo(JsonValueKind.Null);
+ }
+
+ [Test]
+ public async Task ProcessPARequest_CriteriaMapping_AllMetStatusesMappedCorrectly()
+ {
+ // Arrange - Use PA-002 (different patient, still pre-seeded)
+ var paRequestId = "PA-002";
+
+ // Act
+ var result = await _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new
+ {
+ query = @"mutation($id: String!) {
+ processPARequest(id: $id) {
+ id
+ criteria { met label reason }
+ }
+ }",
+ variables = new { id = paRequestId }
+ }).ToUrl("/api/graphql");
+ s.StatusCodeShouldBe(200);
+ }).ConfigureAwait(false);
+
+ // Assert - All 3 evidence items have status "MET" -> met: true
+ var body = result.ReadAsText();
+ using var doc = JsonDocument.Parse(body);
+ var data = doc.RootElement.GetProperty("data").GetProperty("processPARequest");
+ var criteria = data.GetProperty("criteria");
+
+ await Assert.That(criteria.GetArrayLength()).IsEqualTo(3);
+
+ foreach (var criterion in criteria.EnumerateArray())
+ {
+ await Assert.That(criterion.GetProperty("met").GetBoolean()).IsTrue();
+ }
+ }
+}
diff --git a/apps/gateway/Gateway.API.Tests/Services/IntelligenceClientSerializationTests.cs b/apps/gateway/Gateway.API.Tests/Services/IntelligenceClientSerializationTests.cs
new file mode 100644
index 0000000..6de0bf1
--- /dev/null
+++ b/apps/gateway/Gateway.API.Tests/Services/IntelligenceClientSerializationTests.cs
@@ -0,0 +1,180 @@
+using System.Text.Json;
+using Gateway.API.Models;
+using Gateway.API.Services;
+
+namespace Gateway.API.Tests.Services;
+
+///
+/// Tests for IntelligenceClient serialization: verifying that BuildAnalyzeRequest()
+/// correctly maps C# models to the snake_case DTO that the Python Intelligence service expects.
+///
+public class IntelligenceClientSerializationTests
+{
+ private static ClinicalBundle CreateFullBundle()
+ {
+ return new ClinicalBundle
+ {
+ PatientId = "patient-123",
+ Patient = new PatientInfo
+ {
+ Id = "patient-123",
+ GivenName = "Donna",
+ FamilyName = "Sandbox",
+ BirthDate = new DateOnly(1968, 3, 15),
+ Gender = "female",
+ MemberId = "ATH60178"
+ },
+ Conditions =
+ [
+ new ConditionInfo
+ {
+ Id = "cond-1",
+ Code = "M54.5",
+ CodeSystem = "http://hl7.org/fhir/sid/icd-10-cm",
+ Display = "Low back pain",
+ ClinicalStatus = "active"
+ }
+ ],
+ Observations =
+ [
+ new ObservationInfo
+ {
+ Id = "obs-1",
+ Code = "72166-2",
+ CodeSystem = "http://loinc.org",
+ Display = "Smoking status",
+ Value = "Never smoker",
+ Unit = "N/A"
+ }
+ ],
+ Procedures =
+ [
+ new ProcedureInfo
+ {
+ Id = "proc-1",
+ Code = "99213",
+ CodeSystem = "http://www.ama-assn.org/go/cpt",
+ Display = "Office visit",
+ Status = "completed"
+ }
+ ]
+ };
+ }
+
+ [Test]
+ public async Task BuildAnalyzeRequest_MapsPatientFullName_ToNameField()
+ {
+ // Arrange
+ var bundle = CreateFullBundle();
+
+ // Act
+ var dto = IntelligenceClient.BuildAnalyzeRequest(bundle, "72148");
+
+ // Assert
+ await Assert.That(dto.ClinicalData.Patient).IsNotNull();
+ await Assert.That(dto.ClinicalData.Patient!.Name).IsEqualTo("Donna Sandbox");
+ }
+
+ [Test]
+ public async Task BuildAnalyzeRequest_MapsPatientBirthDate_ToIsoString()
+ {
+ // Arrange
+ var bundle = CreateFullBundle();
+
+ // Act
+ var dto = IntelligenceClient.BuildAnalyzeRequest(bundle, "72148");
+
+ // Assert
+ await Assert.That(dto.ClinicalData.Patient!.BirthDate).IsEqualTo("1968-03-15");
+ }
+
+ [Test]
+ public async Task BuildAnalyzeRequest_MapsConditions_ToSnakeCaseKeys()
+ {
+ // Arrange
+ var bundle = CreateFullBundle();
+
+ // Act
+ var dto = IntelligenceClient.BuildAnalyzeRequest(bundle, "72148");
+
+ // Assert
+ await Assert.That(dto.ClinicalData.Conditions).HasCount().EqualTo(1);
+ var condition = dto.ClinicalData.Conditions[0];
+ await Assert.That(condition.Code).IsEqualTo("M54.5");
+ await Assert.That(condition.System).IsEqualTo("http://hl7.org/fhir/sid/icd-10-cm");
+ await Assert.That(condition.Display).IsEqualTo("Low back pain");
+ await Assert.That(condition.ClinicalStatus).IsEqualTo("active");
+ }
+
+ [Test]
+ public async Task BuildAnalyzeRequest_MapsObservations_WithAllFields()
+ {
+ // Arrange
+ var bundle = CreateFullBundle();
+
+ // Act
+ var dto = IntelligenceClient.BuildAnalyzeRequest(bundle, "72148");
+
+ // Assert
+ await Assert.That(dto.ClinicalData.Observations).HasCount().EqualTo(1);
+ var obs = dto.ClinicalData.Observations[0];
+ await Assert.That(obs.Code).IsEqualTo("72166-2");
+ await Assert.That(obs.System).IsEqualTo("http://loinc.org");
+ await Assert.That(obs.Display).IsEqualTo("Smoking status");
+ await Assert.That(obs.Value).IsEqualTo("Never smoker");
+ await Assert.That(obs.Unit).IsEqualTo("N/A");
+ }
+
+ [Test]
+ public async Task BuildAnalyzeRequest_WithNullPatient_SetsNullPatientField()
+ {
+ // Arrange
+ var bundle = new ClinicalBundle
+ {
+ PatientId = "patient-123",
+ Patient = null,
+ Conditions = [],
+ Observations = [],
+ Procedures = []
+ };
+
+ // Act
+ var dto = IntelligenceClient.BuildAnalyzeRequest(bundle, "72148");
+
+ // Assert
+ await Assert.That(dto.ClinicalData.Patient).IsNull();
+ await Assert.That(dto.PatientId).IsEqualTo("patient-123");
+ await Assert.That(dto.ProcedureCode).IsEqualTo("72148");
+ }
+
+ [Test]
+ public async Task SerializeRequest_UsesSnakeCaseNaming()
+ {
+ // Arrange
+ var bundle = CreateFullBundle();
+ var dto = IntelligenceClient.BuildAnalyzeRequest(bundle, "72148");
+
+ // Act
+ var json = JsonSerializer.Serialize(dto, IntelligenceClient.SnakeCaseSerializerOptions);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ // Assert - top-level keys are snake_case
+ await Assert.That(root.TryGetProperty("patient_id", out _)).IsTrue();
+ await Assert.That(root.TryGetProperty("procedure_code", out _)).IsTrue();
+ await Assert.That(root.TryGetProperty("clinical_data", out _)).IsTrue();
+
+ // Assert - nested patient keys are snake_case
+ var clinicalData = root.GetProperty("clinical_data");
+ var patient = clinicalData.GetProperty("patient");
+ await Assert.That(patient.TryGetProperty("name", out _)).IsTrue();
+ await Assert.That(patient.TryGetProperty("birth_date", out _)).IsTrue();
+ await Assert.That(patient.TryGetProperty("gender", out _)).IsTrue();
+ await Assert.That(patient.TryGetProperty("member_id", out _)).IsTrue();
+
+ // Assert - condition keys are snake_case
+ var conditions = clinicalData.GetProperty("conditions");
+ var firstCondition = conditions[0];
+ await Assert.That(firstCondition.TryGetProperty("clinical_status", out _)).IsTrue();
+ }
+}
diff --git a/apps/gateway/Gateway.API.Tests/Services/IntelligenceClientTests.cs b/apps/gateway/Gateway.API.Tests/Services/IntelligenceClientTests.cs
new file mode 100644
index 0000000..618e6b3
--- /dev/null
+++ b/apps/gateway/Gateway.API.Tests/Services/IntelligenceClientTests.cs
@@ -0,0 +1,209 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using Gateway.API.Models;
+using Gateway.API.Services;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Gateway.API.Tests.Services;
+
+///
+/// Tests for IntelligenceClient HTTP behavior: verifying that it
+/// calls the correct endpoint, serializes/deserializes correctly, and handles errors.
+///
+public class IntelligenceClientTests
+{
+ private static (IntelligenceClient client, MockHttpHandler handler) CreateClient(
+ Func> responseFactory)
+ {
+ var handler = new MockHttpHandler(responseFactory);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://test-intelligence:8000") };
+ var logger = NullLogger.Instance;
+ return (new IntelligenceClient(httpClient, logger), handler);
+ }
+
+ private static ClinicalBundle CreateTestBundle()
+ {
+ return new ClinicalBundle
+ {
+ PatientId = "patient-123",
+ Patient = new PatientInfo
+ {
+ Id = "patient-123",
+ GivenName = "Donna",
+ FamilyName = "Sandbox",
+ BirthDate = new DateOnly(1968, 3, 15),
+ Gender = "female",
+ MemberId = "ATH60178"
+ },
+ Conditions =
+ [
+ new ConditionInfo
+ {
+ Id = "cond-1",
+ Code = "M54.5",
+ Display = "Low back pain",
+ ClinicalStatus = "active"
+ }
+ ],
+ Observations =
+ [
+ new ObservationInfo
+ {
+ Id = "obs-1",
+ Code = "72166-2",
+ Display = "Smoking status",
+ Value = "Never smoker"
+ }
+ ],
+ Procedures =
+ [
+ new ProcedureInfo
+ {
+ Id = "proc-1",
+ Code = "99213",
+ Display = "Office visit",
+ Status = "completed"
+ }
+ ]
+ };
+ }
+
+ private static readonly string MockResponseJson = """
+ {
+ "patient_name": "Donna Sandbox",
+ "patient_dob": "1968-03-15",
+ "member_id": "ATH60178",
+ "diagnosis_codes": ["M54.5"],
+ "procedure_code": "72148",
+ "clinical_summary": "AI-generated clinical summary for MRI lumbar spine.",
+ "supporting_evidence": [
+ {
+ "criterion_id": "conservative_therapy",
+ "status": "MET",
+ "evidence": "Patient completed 8 weeks of physical therapy",
+ "source": "Clinical notes",
+ "confidence": 0.95
+ }
+ ],
+ "recommendation": "APPROVE",
+ "confidence_score": 0.92,
+ "field_mappings": {"PatientName": "Donna Sandbox"}
+ }
+ """;
+
+ [Test]
+ public async Task AnalyzeAsync_PostsToAnalyzeEndpoint_WithCorrectPayload()
+ {
+ // Arrange
+ HttpRequestMessage? capturedRequest = null;
+ string? capturedBody = null;
+
+ var (client, _) = CreateClient(async request =>
+ {
+ capturedRequest = request;
+ capturedBody = await request.Content!.ReadAsStringAsync();
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(MockResponseJson, Encoding.UTF8, "application/json")
+ };
+ });
+
+ var bundle = CreateTestBundle();
+
+ // Act
+ await client.AnalyzeAsync(bundle, "72148");
+
+ // Assert - Correct URL and method
+ await Assert.That(capturedRequest).IsNotNull();
+ await Assert.That(capturedRequest!.Method).IsEqualTo(HttpMethod.Post);
+ await Assert.That(capturedRequest.RequestUri!.AbsolutePath).IsEqualTo("/api/analyze");
+
+ // Assert - Body contains snake_case keys
+ await Assert.That(capturedBody).IsNotNull();
+ using var doc = JsonDocument.Parse(capturedBody!);
+ var root = doc.RootElement;
+ await Assert.That(root.TryGetProperty("patient_id", out _)).IsTrue();
+ await Assert.That(root.TryGetProperty("procedure_code", out _)).IsTrue();
+ await Assert.That(root.GetProperty("patient_id").GetString()).IsEqualTo("patient-123");
+ await Assert.That(root.GetProperty("procedure_code").GetString()).IsEqualTo("72148");
+ }
+
+ [Test]
+ public async Task AnalyzeAsync_DeserializesResponse_ToPAFormData()
+ {
+ // Arrange
+ var (client, _) = CreateClient(_ =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(MockResponseJson, Encoding.UTF8, "application/json")
+ }));
+
+ var bundle = CreateTestBundle();
+
+ // Act
+ var result = await client.AnalyzeAsync(bundle, "72148");
+
+ // Assert
+ await Assert.That(result.PatientName).IsEqualTo("Donna Sandbox");
+ await Assert.That(result.PatientDob).IsEqualTo("1968-03-15");
+ await Assert.That(result.MemberId).IsEqualTo("ATH60178");
+ await Assert.That(result.DiagnosisCodes).Contains("M54.5");
+ await Assert.That(result.ProcedureCode).IsEqualTo("72148");
+ await Assert.That(result.ClinicalSummary).IsEqualTo("AI-generated clinical summary for MRI lumbar spine.");
+ await Assert.That(result.Recommendation).IsEqualTo("APPROVE");
+ await Assert.That(result.ConfidenceScore).IsEqualTo(0.92);
+ await Assert.That(result.SupportingEvidence).HasCount().EqualTo(1);
+ await Assert.That(result.SupportingEvidence[0].CriterionId).IsEqualTo("conservative_therapy");
+ await Assert.That(result.SupportingEvidence[0].Status).IsEqualTo("MET");
+ await Assert.That(result.SupportingEvidence[0].Confidence).IsEqualTo(0.95);
+ await Assert.That(result.FieldMappings["PatientName"]).IsEqualTo("Donna Sandbox");
+ }
+
+ [Test]
+ public async Task AnalyzeAsync_WhenServiceReturns400_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var (client, _) = CreateClient(_ =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
+ {
+ Content = new StringContent("{\"detail\":\"Invalid request\"}", Encoding.UTF8, "application/json")
+ }));
+
+ var bundle = CreateTestBundle();
+
+ // Act & Assert
+ await Assert.That(() => client.AnalyzeAsync(bundle, "72148"))
+ .Throws();
+ }
+
+ [Test]
+ public async Task AnalyzeAsync_WhenServiceReturns500_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var (client, _) = CreateClient(_ =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
+ {
+ Content = new StringContent("{\"detail\":\"Internal error\"}", Encoding.UTF8, "application/json")
+ }));
+
+ var bundle = CreateTestBundle();
+
+ // Act & Assert
+ await Assert.That(() => client.AnalyzeAsync(bundle, "72148"))
+ .Throws();
+ }
+}
+
+internal sealed class MockHttpHandler : DelegatingHandler
+{
+ private readonly Func> _handler;
+
+ public MockHttpHandler(Func> handler)
+ {
+ _handler = handler;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ => _handler(request);
+}
diff --git a/apps/gateway/Gateway.API.Tests/Services/MockDataServiceTests.cs b/apps/gateway/Gateway.API.Tests/Services/MockDataServiceTests.cs
index 8225d48..0c6e72c 100644
--- a/apps/gateway/Gateway.API.Tests/Services/MockDataServiceTests.cs
+++ b/apps/gateway/Gateway.API.Tests/Services/MockDataServiceTests.cs
@@ -365,6 +365,112 @@ public async Task BootstrapRequest_PA003_HasUnmetCriteria()
}
}
+ // ── ApplyAnalysisResult ─────────────────────────────────────────────
+
+ [Test]
+ public async Task ApplyAnalysisResult_UpdatesStatus_ToReady()
+ {
+ var svc = CreateService();
+ var existing = svc.GetPARequests().First();
+
+ var result = svc.ApplyAnalysisResult(
+ existing.Id,
+ clinicalSummary: "AI analysis summary",
+ confidence: 85,
+ criteria: [new CriterionModel { Met = true, Label = "test", Reason = "test reason" }]);
+
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result!.Status).IsEqualTo("ready");
+ }
+
+ [Test]
+ public async Task ApplyAnalysisResult_UpdatesConfidence_FromScore()
+ {
+ var svc = CreateService();
+ var existing = svc.GetPARequests().First();
+
+ var result = svc.ApplyAnalysisResult(existing.Id, "summary", 92, []);
+
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result!.Confidence).IsEqualTo(92);
+ }
+
+ [Test]
+ public async Task ApplyAnalysisResult_UpdatesClinicalSummary()
+ {
+ var svc = CreateService();
+ var existing = svc.GetPARequests().First();
+ var summary = "Patient presents with chronic low back pain. AI recommends approval.";
+
+ var result = svc.ApplyAnalysisResult(existing.Id, summary, 85, []);
+
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result!.ClinicalSummary).IsEqualTo(summary);
+ }
+
+ [Test]
+ public async Task ApplyAnalysisResult_UpdatesCriteria_FromEvidence()
+ {
+ var svc = CreateService();
+ var existing = svc.GetPARequests().First();
+ var criteria = new List
+ {
+ new() { Met = true, Label = "conservative_therapy", Reason = "6 weeks PT completed" },
+ new() { Met = false, Label = "imaging_required", Reason = "No prior imaging found" },
+ new() { Met = null, Label = "diagnosis_present", Reason = "Needs verification" },
+ };
+
+ var result = svc.ApplyAnalysisResult(existing.Id, "summary", 75, criteria);
+
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result!.Criteria.Count).IsEqualTo(3);
+ await Assert.That(result.Criteria[0].Met).IsTrue();
+ await Assert.That(result.Criteria[1].Met).IsFalse();
+ await Assert.That(result.Criteria[2].Met).IsNull();
+ }
+
+ [Test]
+ public async Task ApplyAnalysisResult_SetsReadyAtTimestamp()
+ {
+ var svc = CreateService();
+ var existing = svc.GetPARequests().First();
+ var before = DateTime.UtcNow;
+
+ var result = svc.ApplyAnalysisResult(existing.Id, "summary", 85, []);
+
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result!.ReadyAt).IsNotNull();
+ var readyAt = DateTimeOffset.Parse(result.ReadyAt!);
+ await Assert.That(readyAt.UtcDateTime).IsGreaterThanOrEqualTo(before);
+ }
+
+ [Test]
+ public async Task ApplyAnalysisResult_NonExistentId_ReturnsNull()
+ {
+ var svc = CreateService();
+
+ var result = svc.ApplyAnalysisResult("NONEXISTENT", "summary", 85, []);
+
+ await Assert.That(result).IsNull();
+ }
+
+ [Test]
+ public async Task ApplyAnalysisResult_UpdatesAreVisibleViaGetPARequest()
+ {
+ var svc = CreateService();
+ var existing = svc.GetPARequests().First();
+
+ svc.ApplyAnalysisResult(existing.Id, "Updated summary", 90, [
+ new CriterionModel { Met = true, Label = "test_criterion", Reason = "test" }
+ ]);
+
+ var fetched = svc.GetPARequest(existing.Id);
+ await Assert.That(fetched).IsNotNull();
+ await Assert.That(fetched!.ClinicalSummary).IsEqualTo("Updated summary");
+ await Assert.That(fetched.Confidence).IsEqualTo(90);
+ await Assert.That(fetched.Status).IsEqualTo("ready");
+ }
+
// ── Success Rate ────────────────────────────────────────────────────
[Test]
diff --git a/apps/gateway/Gateway.API/DependencyExtensions.cs b/apps/gateway/Gateway.API/DependencyExtensions.cs
index 93e6c09..f02f294 100644
--- a/apps/gateway/Gateway.API/DependencyExtensions.cs
+++ b/apps/gateway/Gateway.API/DependencyExtensions.cs
@@ -206,17 +206,21 @@ public static IServiceCollection AddFhirClients(this IServiceCollection services
/// Adds the Intelligence client to the dependency injection container.
/// Wraps with caching decorator (decorator checks Enabled setting at runtime).
///
- ///
- /// STUB: Currently registers a stub implementation that returns mock data.
- /// Production will add HttpClient configuration for the Intelligence service.
- ///
/// The service collection.
/// The service collection for chaining.
public static IServiceCollection AddIntelligenceClient(this IServiceCollection services)
{
- // STUB: Register stub implementation without HTTP client
- // Production will use: services.AddHttpClient(...)
- services.AddScoped();
+ services.AddOptions()
+ .BindConfiguration(IntelligenceOptions.SectionName)
+ .Validate(o => !string.IsNullOrEmpty(o.BaseUrl), "Intelligence BaseUrl is required");
+
+ services.AddHttpClient()
+ .ConfigureHttpClient((sp, client) =>
+ {
+ var options = sp.GetRequiredService>().Value;
+ client.BaseAddress = new Uri(options.BaseUrl);
+ client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
+ });
// Apply caching decorator (checks Enabled setting at runtime)
services.Decorate();
diff --git a/apps/gateway/Gateway.API/GraphQL/Mutations/Mutation.cs b/apps/gateway/Gateway.API/GraphQL/Mutations/Mutation.cs
index cef1a90..ee5b86a 100644
--- a/apps/gateway/Gateway.API/GraphQL/Mutations/Mutation.cs
+++ b/apps/gateway/Gateway.API/GraphQL/Mutations/Mutation.cs
@@ -1,3 +1,4 @@
+using Gateway.API.Contracts;
using Gateway.API.GraphQL.Inputs;
using Gateway.API.GraphQL.Models;
using Gateway.API.Services;
@@ -40,9 +41,38 @@ public PARequestModel CreatePARequest(CreatePARequestInput input, [Service] Mock
criteria);
}
- public async Task ProcessPARequest(string id, [Service] MockDataService mockData, CancellationToken cancellationToken)
+ public async Task ProcessPARequest(
+ string id,
+ [Service] MockDataService mockData,
+ [Service] IFhirDataAggregator fhirAggregator,
+ [Service] IIntelligenceClient intelligenceClient,
+ CancellationToken ct)
{
- return await mockData.ProcessPARequestAsync(id, cancellationToken);
+ var paRequest = mockData.GetPARequest(id);
+ if (paRequest is null) return null;
+
+ var clinicalBundle = await fhirAggregator.AggregateClinicalDataAsync(
+ paRequest.PatientId, cancellationToken: ct);
+
+ var formData = await intelligenceClient.AnalyzeAsync(
+ clinicalBundle, paRequest.ProcedureCode, ct);
+
+ var criteria = formData.SupportingEvidence.Select(e => new CriterionModel
+ {
+ Met = e.Status switch
+ {
+ "MET" => true,
+ "NOT_MET" => false,
+ _ => null
+ },
+ Label = e.CriterionId,
+ Reason = e.Evidence
+ }).ToList();
+
+ var confidence = (int)(formData.ConfidenceScore * 100);
+
+ return mockData.ApplyAnalysisResult(id,
+ formData.ClinicalSummary, confidence, criteria);
}
public PARequestModel? SubmitPARequest(string id, [Service] MockDataService mockData, int addReviewTimeSeconds = 0)
diff --git a/apps/gateway/Gateway.API/Services/IntelligenceClient.cs b/apps/gateway/Gateway.API/Services/IntelligenceClient.cs
index 8c2b2d5..dc2a6ef 100644
--- a/apps/gateway/Gateway.API/Services/IntelligenceClient.cs
+++ b/apps/gateway/Gateway.API/Services/IntelligenceClient.cs
@@ -1,86 +1,106 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
using Gateway.API.Contracts;
using Gateway.API.Models;
namespace Gateway.API.Services;
///
-/// STUB: Intelligence client that returns mock PA analysis data.
-/// Production implementation will call the Intelligence service HTTP API.
+/// Intelligence client that calls the Python Intelligence service HTTP API
+/// to analyze clinical data and generate prior authorization form data.
///
public sealed class IntelligenceClient : IIntelligenceClient
{
+ private readonly HttpClient _httpClient;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
+ /// HTTP client configured with Intelligence service base URL.
/// Logger for diagnostic output.
- public IntelligenceClient(ILogger logger)
+ public IntelligenceClient(HttpClient httpClient, ILogger logger)
{
+ _httpClient = httpClient;
_logger = logger;
}
///
- public Task AnalyzeAsync(
+ public async Task AnalyzeAsync(
ClinicalBundle clinicalBundle,
string procedureCode,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
- "STUB: Returning mock analysis for ProcedureCode={ProcedureCode}",
- procedureCode);
-
- var patientName = clinicalBundle.Patient?.FullName ?? "Unknown Patient";
- var patientDob = clinicalBundle.Patient?.BirthDate?.ToString("yyyy-MM-dd") ?? "Unknown";
- var memberId = clinicalBundle.Patient?.MemberId ?? "Unknown";
- var diagnosisCodes = clinicalBundle.Conditions
- .Select(c => c.Code)
- .Where(c => !string.IsNullOrEmpty(c))
- .DefaultIfEmpty("M54.5")
- .ToList();
-
- var result = new PAFormData
+ "Calling Intelligence service for PatientId={PatientId}, ProcedureCode={ProcedureCode}",
+ clinicalBundle.PatientId, procedureCode);
+
+ var requestDto = BuildAnalyzeRequest(clinicalBundle, procedureCode);
+
+ var response = await _httpClient.PostAsJsonAsync(
+ "/api/analyze",
+ requestDto,
+ SnakeCaseSerializerOptions,
+ cancellationToken);
+
+ response.EnsureSuccessStatusCode();
+
+ var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
+
+ if (result is null)
{
- PatientName = patientName,
- PatientDob = patientDob,
- MemberId = memberId,
- DiagnosisCodes = diagnosisCodes!,
- ProcedureCode = procedureCode,
- ClinicalSummary = "STUB: Mock clinical summary for demo purposes. " +
- "Production will generate AI-powered clinical justification.",
- SupportingEvidence =
- [
- new EvidenceItem
- {
- CriterionId = "diagnosis_present",
- Status = "MET",
- Evidence = "STUB: Qualifying diagnosis code found",
- Source = "Stub implementation",
- Confidence = 0.95
- },
- new EvidenceItem
- {
- CriterionId = "conservative_therapy",
- Status = "MET",
- Evidence = "STUB: Conservative therapy documented",
- Source = "Stub implementation",
- Confidence = 0.90
- }
- ],
- Recommendation = "APPROVE",
- ConfidenceScore = 0.95,
- FieldMappings = new Dictionary
- {
- ["PatientName"] = patientName,
- ["PatientDOB"] = patientDob,
- ["MemberID"] = memberId,
- ["PrimaryDiagnosis"] = diagnosisCodes.FirstOrDefault() ?? "M54.5",
- ["ProcedureCode"] = procedureCode,
- ["ClinicalJustification"] = "STUB: Clinical justification",
- ["RequestedDateOfService"] = DateTime.Today.ToString("yyyy-MM-dd")
- }
- };
-
- return Task.FromResult(result);
+ throw new InvalidOperationException("Intelligence service returned null response");
+ }
+
+ _logger.LogInformation(
+ "Intelligence analysis complete: Recommendation={Recommendation}, Confidence={Confidence}",
+ result.Recommendation, result.ConfidenceScore);
+
+ return result;
+ }
+
+ internal static readonly JsonSerializerOptions SnakeCaseSerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ internal static AnalyzeRequestDto BuildAnalyzeRequest(ClinicalBundle bundle, string procedureCode)
+ {
+ var patient = bundle.Patient is not null
+ ? new PatientDto(
+ Name: bundle.Patient.FullName,
+ BirthDate: bundle.Patient.BirthDate?.ToString("yyyy-MM-dd"),
+ Gender: bundle.Patient.Gender,
+ MemberId: bundle.Patient.MemberId)
+ : null;
+
+ var conditions = bundle.Conditions.Select(c => new ConditionDto(
+ Code: c.Code, System: c.CodeSystem, Display: c.Display, ClinicalStatus: c.ClinicalStatus)).ToList();
+
+ var observations = bundle.Observations.Select(o => new ObservationDto(
+ Code: o.Code, System: o.CodeSystem, Display: o.Display, Value: o.Value, Unit: o.Unit)).ToList();
+
+ var procedures = bundle.Procedures.Select(p => new ProcedureDto(
+ Code: p.Code, System: p.CodeSystem, Display: p.Display, Status: p.Status)).ToList();
+
+ return new AnalyzeRequestDto(
+ PatientId: bundle.PatientId,
+ ProcedureCode: procedureCode,
+ ClinicalData: new ClinicalDataDto(patient, conditions, observations, procedures));
}
}
+
+internal sealed record AnalyzeRequestDto(string PatientId, string ProcedureCode, ClinicalDataDto ClinicalData);
+
+internal sealed record ClinicalDataDto(
+ PatientDto? Patient,
+ List Conditions,
+ List Observations,
+ List Procedures);
+
+internal sealed record PatientDto(string Name, string? BirthDate, string? Gender, string? MemberId);
+internal sealed record ConditionDto(string Code, string? System, string? Display, string? ClinicalStatus);
+internal sealed record ObservationDto(string Code, string? System, string? Display, string? Value, string? Unit);
+internal sealed record ProcedureDto(string Code, string? System, string? Display, string? Status);
diff --git a/apps/gateway/Gateway.API/Services/MockDataService.cs b/apps/gateway/Gateway.API/Services/MockDataService.cs
index a5c1fd1..a6b91f4 100644
--- a/apps/gateway/Gateway.API/Services/MockDataService.cs
+++ b/apps/gateway/Gateway.API/Services/MockDataService.cs
@@ -258,6 +258,33 @@ public PARequestModel CreatePARequest(PatientModel patient, string procedureCode
return req;
}
+ public PARequestModel? ApplyAnalysisResult(
+ string id,
+ string clinicalSummary,
+ int confidence,
+ IReadOnlyList criteria)
+ {
+ lock (_lock)
+ {
+ var idx = _paRequests.FindIndex(r => r.Id == id);
+ if (idx < 0) return null;
+
+ var existing = _paRequests[idx];
+ var readyAt = DateTime.UtcNow.ToString("O");
+ var updated = existing with
+ {
+ Status = "ready",
+ Confidence = confidence,
+ ClinicalSummary = clinicalSummary,
+ Criteria = criteria.ToList(),
+ ReadyAt = readyAt,
+ UpdatedAt = readyAt,
+ };
+ _paRequests[idx] = updated;
+ return updated;
+ }
+ }
+
public PARequestModel? UpdatePARequest(string id, string? diagnosis, string? diagnosisCode, string? serviceDate, string? placeOfService, string? clinicalSummary, IReadOnlyList? criteria)
{
lock (_lock)
diff --git a/apps/intelligence/src/api/analyze.py b/apps/intelligence/src/api/analyze.py
index 143ddc7..c3c394b 100644
--- a/apps/intelligence/src/api/analyze.py
+++ b/apps/intelligence/src/api/analyze.py
@@ -13,6 +13,7 @@
from src.models.pa_form import PAFormResponse
from src.parsers.pdf_parser import parse_pdf
from src.policies.example_policy import EXAMPLE_POLICY
+from src.policies.generic_policy import build_generic_policy
from src.reasoning.evidence_extractor import extract_evidence
from src.reasoning.form_generator import generate_form_data
@@ -30,6 +31,13 @@ class AnalyzeRequest(BaseModel):
clinical_data: dict[str, Any]
+def resolve_policy(procedure_code: str) -> dict[str, Any]:
+ """Return the specific policy for known procedures, or a generic fallback."""
+ if procedure_code in SUPPORTED_PROCEDURE_CODES:
+ return {**EXAMPLE_POLICY, "procedure_codes": [procedure_code]}
+ return build_generic_policy(procedure_code)
+
+
@router.post("", response_model=PAFormResponse)
async def analyze(request: AnalyzeRequest) -> PAFormResponse:
"""
@@ -37,13 +45,6 @@ async def analyze(request: AnalyzeRequest) -> PAFormResponse:
Uses LLM to extract evidence from clinical data and generate PA form.
"""
- # Check if procedure is supported
- if request.procedure_code not in SUPPORTED_PROCEDURE_CODES:
- raise HTTPException(
- status_code=400,
- detail=f"Procedure code {request.procedure_code} not supported",
- )
-
# Parse clinical data into structured format
bundle = ClinicalBundle.from_dict(request.patient_id, request.clinical_data)
@@ -55,8 +56,8 @@ async def analyze(request: AnalyzeRequest) -> PAFormResponse:
detail="patient.birth_date is required",
)
- # Load policy with requested procedure code
- policy = {**EXAMPLE_POLICY, "procedure_codes": [request.procedure_code]}
+ # Load specific policy or fall back to generic
+ policy = resolve_policy(request.procedure_code)
# Extract evidence using LLM
evidence = await extract_evidence(bundle, policy)
@@ -87,13 +88,6 @@ async def analyze_with_documents(
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid clinical data JSON: {e}")
- # Check if procedure is supported
- if procedure_code not in SUPPORTED_PROCEDURE_CODES:
- raise HTTPException(
- status_code=400,
- detail=f"Procedure code {procedure_code} not supported",
- )
-
# Parse clinical data into structured format
bundle = ClinicalBundle.from_dict(patient_id, clinical_data_dict)
@@ -112,8 +106,8 @@ async def analyze_with_documents(
detail="patient.birth_date is required",
)
- # Load policy with requested procedure code
- policy = {**EXAMPLE_POLICY, "procedure_codes": [procedure_code]}
+ # Load specific policy or fall back to generic
+ policy = resolve_policy(procedure_code)
# Extract evidence using LLM
evidence = await extract_evidence(bundle, policy)
diff --git a/apps/intelligence/src/policies/generic_policy.py b/apps/intelligence/src/policies/generic_policy.py
new file mode 100644
index 0000000..47efa77
--- /dev/null
+++ b/apps/intelligence/src/policies/generic_policy.py
@@ -0,0 +1,76 @@
+"""Generic policy builder for unsupported procedure codes.
+
+When a procedure code is not in the specific policy set, we fall back to
+a generic policy that uses broad criteria the LLM can evaluate against
+any clinical documentation.
+"""
+
+from typing import Any
+
+
+def build_generic_policy(procedure_code: str) -> dict[str, Any]:
+ """Build a generic prior authorization policy for any procedure code.
+
+ The generic policy uses broad criteria (medical necessity, diagnosis
+ validation, clinical documentation) that the LLM can evaluate against
+ any clinical data, regardless of procedure type.
+ """
+ return {
+ "policy_id": f"generic-{procedure_code}",
+ "policy_name": f"Generic Prior Authorization - {procedure_code}",
+ "payer": "Generic",
+ "procedure_codes": [procedure_code],
+ "diagnosis_codes": {
+ "primary": [],
+ "supporting": [],
+ },
+ "criteria": [
+ {
+ "id": "medical_necessity",
+ "description": "The requested procedure is medically necessary based on the clinical documentation",
+ "evidence_patterns": [
+ r"medically necessary",
+ r"medical necessity",
+ r"clinically indicated",
+ r"recommended.*procedure",
+ r"required.*treatment",
+ ],
+ "required": True,
+ },
+ {
+ "id": "diagnosis_present",
+ "description": "Valid ICD-10 diagnosis code present supporting the procedure",
+ "evidence_patterns": [
+ r"[A-Z]\d{2}\.?\d{0,4}",
+ r"diagnosis",
+ r"diagnosed with",
+ ],
+ "required": True,
+ },
+ {
+ "id": "clinical_documentation",
+ "description": "Sufficient clinical documentation supports the request",
+ "evidence_patterns": [
+ r"clinical.*documentation",
+ r"medical records",
+ r"chart.*notes",
+ r"history.*physical",
+ r"assessment.*plan",
+ ],
+ "required": True,
+ },
+ ],
+ "form_field_mappings": {
+ "patient_name": "PatientName",
+ "patient_dob": "PatientDOB",
+ "member_id": "MemberID",
+ "diagnosis_primary": "PrimaryDiagnosis",
+ "diagnosis_secondary": "SecondaryDiagnosis",
+ "procedure_code": "ProcedureCode",
+ "clinical_summary": "ClinicalJustification",
+ "provider_name": "OrderingProviderName",
+ "provider_npi": "OrderingProviderNPI",
+ "facility_name": "FacilityName",
+ "date_of_service": "RequestedDateOfService",
+ },
+ }
diff --git a/apps/intelligence/src/tests/test_analyze.py b/apps/intelligence/src/tests/test_analyze.py
index bb2491d..8c6044c 100644
--- a/apps/intelligence/src/tests/test_analyze.py
+++ b/apps/intelligence/src/tests/test_analyze.py
@@ -52,19 +52,20 @@ async def test_analyze_extracts_patient_info(valid_request: AnalyzeRequest) -> N
@pytest.mark.asyncio
-async def test_analyze_rejects_unsupported_procedure() -> None:
- """Stub should reject unsupported procedure codes."""
+async def test_analyze_unsupported_procedure_uses_generic_fallback() -> None:
+ """Unsupported procedure codes now fall back to generic policy instead of 400."""
request = AnalyzeRequest(
patient_id="test",
procedure_code="99999",
clinical_data={"patient": {"name": "Test", "birth_date": "1980-01-01"}},
)
-
- with pytest.raises(HTTPException) as exc_info:
- await analyze(request)
-
- assert exc_info.value.status_code == 400
- assert "not supported" in exc_info.value.detail
+ mock_llm = AsyncMock(return_value="The criterion is MET based on the evidence.")
+ with (
+ patch("src.reasoning.evidence_extractor.chat_completion", mock_llm),
+ patch("src.reasoning.form_generator.chat_completion", mock_llm),
+ ):
+ result = await analyze(request)
+ assert result.procedure_code == "99999"
@pytest.mark.asyncio
@@ -92,3 +93,73 @@ async def test_analyze_builds_field_mappings(valid_request: AnalyzeRequest) -> N
assert "PatientDOB" in result.field_mappings
assert "ProcedureCode" in result.field_mappings
assert result.field_mappings["PatientName"] == "John Doe"
+
+
+@pytest.mark.asyncio
+async def test_analyze_unsupported_procedure_uses_generic_policy():
+ """POST /analyze with unsupported procedure returns 200, not 400."""
+ request = AnalyzeRequest(
+ patient_id="test-123",
+ procedure_code="27447", # Total Knee Replacement - not in SUPPORTED_PROCEDURE_CODES
+ clinical_data={
+ "patient": {"name": "Test Patient", "birth_date": "1968-03-15", "member_id": "MEM001"},
+ "conditions": [{"code": "M17.11", "display": "Primary OA Right Knee"}],
+ "observations": [],
+ "procedures": [],
+ },
+ )
+ mock_llm = AsyncMock(return_value="The criterion is MET based on the clinical evidence provided.")
+ with (
+ patch("src.reasoning.evidence_extractor.chat_completion", mock_llm),
+ patch("src.reasoning.form_generator.chat_completion", mock_llm),
+ ):
+ result = await analyze(request)
+ assert result.procedure_code == "27447"
+
+
+@pytest.mark.asyncio
+async def test_analyze_generic_policy_returns_valid_response():
+ """Generic policy response has all required PAFormResponse fields."""
+ request = AnalyzeRequest(
+ patient_id="test-456",
+ procedure_code="43239", # Upper GI Endoscopy
+ clinical_data={
+ "patient": {"name": "Jane Doe", "birth_date": "1975-07-22", "member_id": "MEM002"},
+ "conditions": [{"code": "K21.0", "display": "GERD with Esophagitis"}],
+ "observations": [],
+ "procedures": [],
+ },
+ )
+ mock_llm = AsyncMock(return_value="The criterion is MET based on the evidence.")
+ with (
+ patch("src.reasoning.evidence_extractor.chat_completion", mock_llm),
+ patch("src.reasoning.form_generator.chat_completion", mock_llm),
+ ):
+ result = await analyze(request)
+ assert result.patient_name is not None
+ assert result.confidence_score >= 0
+ assert len(result.supporting_evidence) > 0
+
+
+@pytest.mark.asyncio
+async def test_analyze_known_procedure_still_uses_specific_policy():
+ """72148 (MRI Lumbar) still uses the specific MRI policy, not generic."""
+ request = AnalyzeRequest(
+ patient_id="test-789",
+ procedure_code="72148",
+ clinical_data={
+ "patient": {"name": "Bob Smith", "birth_date": "1982-11-08", "member_id": "MEM003"},
+ "conditions": [{"code": "M54.5", "display": "Low Back Pain"}],
+ "observations": [],
+ "procedures": [],
+ },
+ )
+ mock_llm = AsyncMock(return_value="The criterion is MET based on the evidence.")
+ with (
+ patch("src.reasoning.evidence_extractor.chat_completion", mock_llm),
+ patch("src.reasoning.form_generator.chat_completion", mock_llm),
+ ):
+ result = await analyze(request)
+ # MRI policy has conservative_therapy criterion; generic doesn't
+ evidence_ids = [e.criterion_id for e in result.supporting_evidence]
+ assert "conservative_therapy" in evidence_ids
diff --git a/apps/intelligence/src/tests/test_generic_policy.py b/apps/intelligence/src/tests/test_generic_policy.py
new file mode 100644
index 0000000..588c553
--- /dev/null
+++ b/apps/intelligence/src/tests/test_generic_policy.py
@@ -0,0 +1,42 @@
+"""Tests for generic policy builder."""
+import pytest
+from src.policies.generic_policy import build_generic_policy
+
+
+def test_build_generic_policy_returns_valid_structure():
+ """Policy has all required keys matching EXAMPLE_POLICY structure."""
+ policy = build_generic_policy("27447")
+ assert "policy_id" in policy
+ assert "criteria" in policy
+ assert "form_field_mappings" in policy
+ assert "procedure_codes" in policy
+ assert "diagnosis_codes" in policy
+
+
+def test_build_generic_policy_includes_medical_necessity_criterion():
+ """Generic policy always includes medical necessity criterion."""
+ policy = build_generic_policy("27447")
+ criterion_ids = [c["id"] for c in policy["criteria"]]
+ assert "medical_necessity" in criterion_ids
+
+
+def test_build_generic_policy_includes_diagnosis_criterion():
+ """Generic policy always includes diagnosis validation criterion."""
+ policy = build_generic_policy("27447")
+ criterion_ids = [c["id"] for c in policy["criteria"]]
+ assert "diagnosis_present" in criterion_ids
+
+
+def test_build_generic_policy_uses_procedure_code():
+ """Procedure code is set in the policy."""
+ policy = build_generic_policy("27447")
+ assert "27447" in policy["procedure_codes"]
+
+
+def test_build_generic_policy_criteria_have_required_fields():
+ """Each criterion has id, description, and required fields."""
+ policy = build_generic_policy("99999")
+ for criterion in policy["criteria"]:
+ assert "id" in criterion
+ assert "description" in criterion
+ assert "required" in criterion