diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3d79e22 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,50 @@ +# =========================================================================== +# Root .dockerignore for monorepo Docker builds +# =========================================================================== + +# Git +.git/ +.gitignore + +# Node modules (will be installed fresh in container) +**/node_modules/ + +# Python virtual environments +**/.venv/ +**/__pycache__/ +**/*.pyc + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Test artifacts +**/coverage/ +**/.vitest/ +**/.pytest_cache/ + +# Build artifacts we don't need +**/*.log +**/npm-debug.log* + +# OS files +.DS_Store +Thumbs.db + +# Documentation (not needed in containers) +docs/ + +# Orchestration (not needed in app containers) +orchestration/ + +# Temp files +*.txt +*.tmp + +# Keep these (explicitly) +!apps/dashboard/dist/ +!apps/dashboard/nginx.conf +!shared/types/dist/ +!shared/validation/dist/ diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml index 1b75f62..1ae6f2b 100644 --- a/.github/workflows/project-automation.yml +++ b/.github/workflows/project-automation.yml @@ -205,6 +205,268 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # ============================================================ + # PROJECT: Add PRs to Project #4 with workstream assignment + # ============================================================ + add-pr-to-project: + if: | + github.event_name == 'pull_request' && + github.event.action != 'closed' + runs-on: ubuntu-latest + steps: + - name: Checkout for file detection + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Add PR to project + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const pr = context.payload.pull_request; + + // Get project and field info + const projectResult = await github.graphql(` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + projectV2(number: $number) { + id + fields(first: 20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + } + } + } + `, { + owner: context.repo.owner, + repo: context.repo.repo, + number: parseInt(process.env.PROJECT_NUMBER) + }); + + const project = projectResult.repository.projectV2; + if (!project) { + console.log(`Project #${process.env.PROJECT_NUMBER} not found`); + return; + } + + const workstreamField = project.fields.nodes.find(f => f.name === 'Workstream'); + + // Add PR to project + const addItemMutation = ` + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { id } + } + } + `; + + const addResult = await github.graphql(addItemMutation, { + projectId: project.id, + contentId: pr.node_id + }); + + const itemId = addResult.addProjectV2ItemById.item.id; + console.log(`Added PR #${pr.number} to project, item ID: ${itemId}`); + + // Determine workstream based on changed files + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100 + }); + + const touchesGateway = files.some(f => f.filename.startsWith('apps/gateway/')); + const touchesIntelligence = files.some(f => f.filename.startsWith('apps/intelligence/')); + + let workstreamValue = null; + if (touchesGateway && !touchesIntelligence) { + workstreamValue = 'Gateway (.NET)'; + } else if (touchesIntelligence && !touchesGateway) { + workstreamValue = 'Intelligence (Python)'; + } + // If both or neither, leave unset + + if (workstreamValue && workstreamField) { + const option = workstreamField.options.find(o => o.name === workstreamValue); + if (option) { + const updateFieldMutation = ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + } + `; + + await github.graphql(updateFieldMutation, { + projectId: project.id, + itemId: itemId, + fieldId: workstreamField.id, + optionId: option.id + }); + + console.log(`Set Workstream to: ${workstreamValue}`); + } + } + + # ============================================================ + # PROJECT: Add issues to Project #4 with priority and workstream + # ============================================================ + add-issue-to-project: + if: github.event_name == 'issues' && github.event.action == 'opened' + needs: auto-triage + runs-on: ubuntu-latest + steps: + - name: Add issue to project + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const issue = context.payload.issue; + + // Get project and field info + const projectResult = await github.graphql(` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + projectV2(number: $number) { + id + fields(first: 20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + } + } + } + `, { + owner: context.repo.owner, + repo: context.repo.repo, + number: parseInt(process.env.PROJECT_NUMBER) + }); + + const project = projectResult.repository.projectV2; + if (!project) { + console.log(`Project #${process.env.PROJECT_NUMBER} not found`); + return; + } + + const priorityField = project.fields.nodes.find(f => f.name === 'Priority'); + const workstreamField = project.fields.nodes.find(f => f.name === 'Workstream'); + + // Add issue to project + const addItemMutation = ` + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { id } + } + } + `; + + const addResult = await github.graphql(addItemMutation, { + projectId: project.id, + contentId: issue.node_id + }); + + const itemId = addResult.addProjectV2ItemById.item.id; + console.log(`Added issue #${issue.number} to project, item ID: ${itemId}`); + + // Re-fetch issue to get labels (auto-triage may have added them) + const { data: freshIssue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number + }); + + const labels = freshIssue.labels.map(l => l.name); + + // Set Priority based on labels + if (priorityField) { + let priorityValue = 'Medium'; // Default + if (labels.includes('priority:high')) { + priorityValue = 'High'; + } else if (labels.includes('priority:low')) { + priorityValue = 'Low'; + } + + const option = priorityField.options.find(o => o.name === priorityValue); + if (option) { + const updateFieldMutation = ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + } + `; + + await github.graphql(updateFieldMutation, { + projectId: project.id, + itemId: itemId, + fieldId: priorityField.id, + optionId: option.id + }); + + console.log(`Set Priority to: ${priorityValue}`); + } + } + + // Set Workstream based on scope labels + if (workstreamField) { + let workstreamValue = null; + if (labels.includes('scope:gateway')) { + workstreamValue = 'Gateway (.NET)'; + } else if (labels.includes('scope:intelligence')) { + workstreamValue = 'Intelligence (Python)'; + } + + if (workstreamValue) { + const option = workstreamField.options.find(o => o.name === workstreamValue); + if (option) { + const updateFieldMutation = ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + } + `; + + await github.graphql(updateFieldMutation, { + projectId: project.id, + itemId: itemId, + fieldId: workstreamField.id, + optionId: option.id + }); + + console.log(`Set Workstream to: ${workstreamValue}`); + } + } + } + # ============================================================ # RELEASE AUTOMATION: Generate changelog and release # ============================================================ diff --git a/.gitignore b/.gitignore index 196adb6..83acc6f 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,4 @@ assets/pdf-templates/*.pdf # Workflow state (session-specific) docs/workflow-state/ +.worktrees/ diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile index d4b616b..79f9f42 100644 --- a/apps/dashboard/Dockerfile +++ b/apps/dashboard/Dockerfile @@ -11,10 +11,13 @@ FROM node:22-alpine AS builder WORKDIR /app # Copy package files first for layer caching -COPY package.json package-lock.json* ./ +COPY package.json ./ # Install dependencies -RUN npm ci +# Note: Using npm install since this Dockerfile builds in isolation without +# the monorepo's package-lock.json. For reproducible CI/CD builds, use +# Dockerfile.build from the monorepo root instead. +RUN npm install # Copy source and build COPY . ./ diff --git a/apps/dashboard/Dockerfile.build b/apps/dashboard/Dockerfile.build new file mode 100644 index 0000000..a67781b --- /dev/null +++ b/apps/dashboard/Dockerfile.build @@ -0,0 +1,55 @@ +# =========================================================================== +# AuthScript Dashboard - Full Build Dockerfile +# For CI/CD when pre-built assets aren't available +# +# Build from monorepo root: +# docker build -f apps/dashboard/Dockerfile.build -t authscript-dashboard . +# =========================================================================== + +# --------------------------------------------------------------------------- +# Stage 1: Build +# --------------------------------------------------------------------------- +FROM docker.io/library/node:22-alpine AS builder + +WORKDIR /app + +# Copy workspace config and lock file +COPY package.json package-lock.json ./ + +# Copy shared packages +COPY shared/types ./shared/types +COPY shared/validation ./shared/validation + +# Copy dashboard package.json for dependency resolution +COPY apps/dashboard/package.json ./apps/dashboard/ + +# Install all workspace dependencies with cache mount +RUN --mount=type=cache,target=/root/.npm \ + npm ci --workspace=apps/dashboard --workspace=shared/types --workspace=shared/validation + +# Build shared packages first +RUN npm run build -w shared/types && npm run build -w shared/validation + +# Copy dashboard source +COPY apps/dashboard ./apps/dashboard + +# Build dashboard +RUN npm run build -w apps/dashboard + +# --------------------------------------------------------------------------- +# Stage 2: Runtime with nginx +# --------------------------------------------------------------------------- +FROM docker.io/library/nginx:1.27-alpine + +# Copy built assets +COPY --from=builder /app/apps/dashboard/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY apps/dashboard/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost/health || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/dashboard/src/api/generated/analysis/analysis.ts b/apps/dashboard/src/api/generated/analysis/analysis.ts index 89d6f9f..edf68a4 100644 --- a/apps/dashboard/src/api/generated/analysis/analysis.ts +++ b/apps/dashboard/src/api/generated/analysis/analysis.ts @@ -35,11 +35,8 @@ import type { /** * Analyze clinical data and generate PA form response. -This endpoint: -1. Validates the procedure code against supported policies -2. Extracts evidence from clinical data -3. Evaluates against policy criteria -4. Generates form field values +STUB IMPLEMENTATION: Always returns APPROVE with 1.0 confidence. +Production version would evaluate clinical data against payer policies. * @summary Analyze */ export type analyzeAnalyzePostResponse200 = { @@ -138,13 +135,8 @@ export const useAnalyzeAnalyzePost = .Success("test"); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("test"); + await Assert.That(result.Error).IsNull(); + } + + [Test] + public async Task Result_Failure_ContainsError() + { + var error = new Error("TEST", "Test error", ErrorType.Validation); + var result = Result.Failure(error); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(error); + } + + [Test] + public async Task Result_Match_ExecutesCorrectBranch() + { + var success = Result.Success(42); + var failure = Result.Failure(new Error("E", "err")); + + var successResult = success.Match(v => $"ok:{v}", e => $"fail:{e.Code}"); + var failureResult = failure.Match(v => $"ok:{v}", e => $"fail:{e.Code}"); + + await Assert.That(successResult).IsEqualTo("ok:42"); + await Assert.That(failureResult).IsEqualTo("fail:E"); + } + + [Test] + public async Task Result_Map_TransformsSuccessValue() + { + var success = Result.Success(5); + var failure = Result.Failure(new Error("E", "err")); + + var mappedSuccess = success.Map(x => x * 2); + var mappedFailure = failure.Map(x => x * 2); + + await Assert.That(mappedSuccess.Value).IsEqualTo(10); + await Assert.That(mappedFailure.IsFailure).IsTrue(); + } + + [Test] + public async Task Result_ImplicitConversion_FromValue() + { + Result result = "implicit value"; + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("implicit value"); + } + + [Test] + public async Task Result_ImplicitConversion_FromError() + { + var error = new Error("E", "err"); + Result result = error; + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(error); + } +} diff --git a/apps/gateway/Gateway.API.Tests/Configuration/EpicFhirOptionsTests.cs b/apps/gateway/Gateway.API.Tests/Configuration/EpicFhirOptionsTests.cs new file mode 100644 index 0000000..e18a5a0 --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Configuration/EpicFhirOptionsTests.cs @@ -0,0 +1,43 @@ +namespace Gateway.API.Tests.Configuration; + +using Gateway.API.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +public class EpicFhirOptionsTests +{ + [Test] + public async Task EpicFhirOptions_Binding_LoadsFromConfiguration() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Epic:FhirBaseUrl"] = "https://fhir.epic.com/api/FHIR/R4", + ["Epic:ClientId"] = "test-client-id", + ["Epic:ClientSecret"] = "test-secret", + ["Epic:TokenEndpoint"] = "https://fhir.epic.com/oauth2/token" + }) + .Build(); + + var services = new ServiceCollection(); + services.Configure(config.GetSection("Epic")); + using var provider = services.BuildServiceProvider(); + + // Act + var options = provider.GetRequiredService>().Value; + + // Assert + await Assert.That(options.FhirBaseUrl).IsEqualTo("https://fhir.epic.com/api/FHIR/R4"); + await Assert.That(options.ClientId).IsEqualTo("test-client-id"); + await Assert.That(options.ClientSecret).IsEqualTo("test-secret"); + await Assert.That(options.TokenEndpoint).IsEqualTo("https://fhir.epic.com/oauth2/token"); + } + + [Test] + public async Task EpicFhirOptions_SectionName_IsEpic() + { + await Assert.That(EpicFhirOptions.SectionName).IsEqualTo("Epic"); + } +} diff --git a/apps/gateway/Gateway.API.Tests/Configuration/IntelligenceOptionsTests.cs b/apps/gateway/Gateway.API.Tests/Configuration/IntelligenceOptionsTests.cs new file mode 100644 index 0000000..4fbc085 --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Configuration/IntelligenceOptionsTests.cs @@ -0,0 +1,49 @@ +namespace Gateway.API.Tests.Configuration; + +using Gateway.API.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +public class IntelligenceOptionsTests +{ + [Test] + public async Task IntelligenceOptions_Binding_LoadsFromConfiguration() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Intelligence:BaseUrl"] = "http://localhost:8000", + ["Intelligence:TimeoutSeconds"] = "60" + }) + .Build(); + + var services = new ServiceCollection(); + services.Configure(config.GetSection("Intelligence")); + using var provider = services.BuildServiceProvider(); + + // Act + var options = provider.GetRequiredService>().Value; + + // Assert + await Assert.That(options.BaseUrl).IsEqualTo("http://localhost:8000"); + await Assert.That(options.TimeoutSeconds).IsEqualTo(60); + } + + [Test] + public async Task IntelligenceOptions_TimeoutSeconds_DefaultsTo30() + { + // Arrange & Act + var options = new IntelligenceOptions { BaseUrl = "http://test" }; + + // Assert + await Assert.That(options.TimeoutSeconds).IsEqualTo(30); + } + + [Test] + public async Task IntelligenceOptions_SectionName_IsIntelligence() + { + await Assert.That(IntelligenceOptions.SectionName).IsEqualTo("Intelligence"); + } +} diff --git a/apps/gateway/Gateway.API.Tests/Configuration/ResiliencyOptionsTests.cs b/apps/gateway/Gateway.API.Tests/Configuration/ResiliencyOptionsTests.cs new file mode 100644 index 0000000..b7cbbf8 --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Configuration/ResiliencyOptionsTests.cs @@ -0,0 +1,26 @@ +namespace Gateway.API.Tests.Configuration; + +using Gateway.API.Configuration; + +public class ResiliencyOptionsTests +{ + [Test] + public async Task ResiliencyOptions_Defaults_HaveReasonableValues() + { + // Arrange & Act + var options = new ResiliencyOptions(); + + // Assert + await Assert.That(options.MaxRetryAttempts).IsEqualTo(3); + await Assert.That(options.RetryDelaySeconds).IsEqualTo(1.0); + await Assert.That(options.TimeoutSeconds).IsEqualTo(10); + await Assert.That(options.CircuitBreakerThreshold).IsEqualTo(5); + await Assert.That(options.CircuitBreakerDurationSeconds).IsEqualTo(30); + } + + [Test] + public async Task ResiliencyOptions_SectionName_IsResilience() + { + await Assert.That(ResiliencyOptions.SectionName).IsEqualTo("Resilience"); + } +} diff --git a/apps/gateway/Gateway.API.Tests/Endpoints/AnalysisEndpointsTests.cs b/apps/gateway/Gateway.API.Tests/Endpoints/AnalysisEndpointsTests.cs index fcbc8fd..f91ba9f 100644 --- a/apps/gateway/Gateway.API.Tests/Endpoints/AnalysisEndpointsTests.cs +++ b/apps/gateway/Gateway.API.Tests/Endpoints/AnalysisEndpointsTests.cs @@ -13,15 +13,15 @@ namespace Gateway.API.Tests.Endpoints; /// public class AnalysisEndpointsTests { - private readonly IDemoCacheService _cacheService; + private readonly IAnalysisResultStore _resultStore; private readonly IPdfFormStamper _pdfStamper; - private readonly IEpicUploader _epicUploader; + private readonly IDocumentUploader _documentUploader; public AnalysisEndpointsTests() { - _cacheService = Substitute.For(); + _resultStore = Substitute.For(); _pdfStamper = Substitute.For(); - _epicUploader = Substitute.For(); + _documentUploader = Substitute.For(); } private static PAFormData CreateTestFormData(string patientName = "John Doe") @@ -64,7 +64,7 @@ public async Task GetAnalysis_WhenAnalysisExists_ReturnsAnalysisData() const string transactionId = "txn-12345"; var expectedFormData = CreateTestFormData(); - _cacheService + _resultStore .GetCachedResponseAsync(transactionId, Arg.Any()) .Returns(expectedFormData); @@ -88,7 +88,7 @@ public async Task GetAnalysis_WhenNotFound_Returns404() // Arrange const string transactionId = "txn-nonexistent"; - _cacheService + _resultStore .GetCachedResponseAsync(transactionId, Arg.Any()) .Returns((PAFormData?)null); @@ -107,7 +107,7 @@ private async Task InvokeGetAnalysis(string transactionId) { return await Gateway.API.Endpoints.AnalysisEndpoints.GetAnalysisAsync( transactionId, - _cacheService, + _resultStore, CancellationToken.None); } @@ -122,7 +122,7 @@ public async Task GetStatus_WhenAnalysisComplete_ReturnsCompletedStatus() const string transactionId = "txn-12345"; var formData = CreateTestFormData(); - _cacheService + _resultStore .GetCachedResponseAsync(transactionId, Arg.Any()) .Returns(formData); @@ -145,7 +145,7 @@ public async Task GetStatus_WhenNotInCache_ReturnsInProgressStatus() // Arrange const string transactionId = "txn-pending"; - _cacheService + _resultStore .GetCachedResponseAsync(transactionId, Arg.Any()) .Returns((PAFormData?)null); @@ -165,7 +165,7 @@ private async Task InvokeGetStatus(string transactionId) { return await Gateway.API.Endpoints.AnalysisEndpoints.GetAnalysisStatusAsync( transactionId, - _cacheService, + _resultStore, CancellationToken.None); } @@ -180,7 +180,7 @@ public async Task DownloadForm_WhenPdfCached_ReturnsCachedPdf() const string transactionId = "txn-12345"; var expectedPdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // PDF magic bytes - _cacheService + _resultStore .GetCachedPdfAsync(transactionId, Arg.Any()) .Returns(expectedPdfBytes); @@ -202,11 +202,11 @@ public async Task DownloadForm_WhenPdfNotCachedButFormDataExists_GeneratesAndCac var formData = CreateTestFormData(); var generatedPdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D }; // PDF magic bytes - _cacheService + _resultStore .GetCachedPdfAsync(transactionId, Arg.Any()) .Returns((byte[]?)null); - _cacheService + _resultStore .GetCachedResponseAsync(transactionId, Arg.Any()) .Returns(formData); @@ -223,7 +223,7 @@ public async Task DownloadForm_WhenPdfNotCachedButFormDataExists_GeneratesAndCac await Assert.That(fileResult).IsNotNull(); // Verify PDF was cached - await _cacheService.Received(1).SetCachedPdfAsync( + await _resultStore.Received(1).SetCachedPdfAsync( transactionId, generatedPdfBytes, Arg.Any()); @@ -235,11 +235,11 @@ public async Task DownloadForm_WhenNoAnalysisData_Returns404() // Arrange const string transactionId = "txn-nonexistent"; - _cacheService + _resultStore .GetCachedPdfAsync(transactionId, Arg.Any()) .Returns((byte[]?)null); - _cacheService + _resultStore .GetCachedResponseAsync(transactionId, Arg.Any()) .Returns((PAFormData?)null); @@ -257,49 +257,49 @@ private async Task InvokeDownloadForm(string transactionId) { return await Gateway.API.Endpoints.AnalysisEndpoints.DownloadFormAsync( transactionId, - _cacheService, + _resultStore, _pdfStamper, CancellationToken.None); } #endregion - #region SubmitToEpic Tests + #region SubmitToFhir Tests [Test] - public async Task SubmitToEpic_WhenAnalysisExists_CallsUploaderAndReturnsSuccess() + public async Task SubmitToFhir_WhenAnalysisExists_CallsUploaderAndReturnsSuccess() { // Arrange const string transactionId = "txn-12345"; const string documentId = "doc-uploaded-123"; var formData = CreateTestFormData(); var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; - var request = new SubmitToEpicRequest + var request = new SubmitToFhirRequest { PatientId = "patient-123", EncounterId = "encounter-456", AccessToken = "bearer-token-xyz" }; - _cacheService + _resultStore .GetCachedResponseAsync(transactionId, Arg.Any()) .Returns(formData); - _cacheService + _resultStore .GetCachedPdfAsync(transactionId, Arg.Any()) .Returns(pdfBytes); - _epicUploader + _documentUploader .UploadDocumentAsync( pdfBytes, request.PatientId, request.EncounterId, request.AccessToken, Arg.Any()) - .Returns(documentId); + .Returns(Result.Success(documentId)); // Act - var result = await InvokeSubmitToEpic(transactionId, request); + var result = await InvokeSubmitToFhir(transactionId, request); // Assert await Assert.That(result).IsNotNull(); @@ -310,7 +310,7 @@ public async Task SubmitToEpic_WhenAnalysisExists_CallsUploaderAndReturnsSuccess await Assert.That(okResult.Value.DocumentId).IsEqualTo(documentId); // Verify uploader was called - await _epicUploader.Received(1).UploadDocumentAsync( + await _documentUploader.Received(1).UploadDocumentAsync( pdfBytes, request.PatientId, request.EncounterId, @@ -319,26 +319,26 @@ await _epicUploader.Received(1).UploadDocumentAsync( } [Test] - public async Task SubmitToEpic_WhenNoPdfAvailable_Returns404() + public async Task SubmitToFhir_WhenNoPdfAvailable_Returns404() { // Arrange const string transactionId = "txn-nonexistent"; - var request = new SubmitToEpicRequest + var request = new SubmitToFhirRequest { PatientId = "patient-123", AccessToken = "bearer-token-xyz" }; - _cacheService + _resultStore .GetCachedPdfAsync(transactionId, Arg.Any()) .Returns((byte[]?)null); - _cacheService + _resultStore .GetCachedResponseAsync(transactionId, Arg.Any()) .Returns((PAFormData?)null); // Act - var result = await InvokeSubmitToEpic(transactionId, request); + var result = await InvokeSubmitToFhir(transactionId, request); // Assert await Assert.That(result).IsNotNull(); @@ -347,37 +347,37 @@ public async Task SubmitToEpic_WhenNoPdfAvailable_Returns404() } [Test] - public async Task SubmitToEpic_WhenUploadFails_ReturnsError() + public async Task SubmitToFhir_WhenUploadFails_ReturnsError() { // Arrange const string transactionId = "txn-12345"; var formData = CreateTestFormData(); var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; - var request = new SubmitToEpicRequest + var request = new SubmitToFhirRequest { PatientId = "patient-123", AccessToken = "bearer-token-xyz" }; - _cacheService + _resultStore .GetCachedResponseAsync(transactionId, Arg.Any()) .Returns(formData); - _cacheService + _resultStore .GetCachedPdfAsync(transactionId, Arg.Any()) .Returns(pdfBytes); - _epicUploader + _documentUploader .UploadDocumentAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Throws(new HttpRequestException("Epic returned 401")); + .Returns(Result.Failure(FhirError.Unauthorized("FHIR returned 401"))); // Act - var result = await InvokeSubmitToEpic(transactionId, request); + var result = await InvokeSubmitToFhir(transactionId, request); // Assert await Assert.That(result).IsNotNull(); @@ -385,13 +385,13 @@ public async Task SubmitToEpic_WhenUploadFails_ReturnsError() await Assert.That(problemResult).IsNotNull(); } - private async Task InvokeSubmitToEpic(string transactionId, SubmitToEpicRequest request) + private async Task InvokeSubmitToFhir(string transactionId, SubmitToFhirRequest request) { - return await Gateway.API.Endpoints.AnalysisEndpoints.SubmitToEpicAsync( + return await Gateway.API.Endpoints.AnalysisEndpoints.SubmitToFhirAsync( transactionId, request, - _epicUploader, - _cacheService, + _documentUploader, + _resultStore, _pdfStamper, CancellationToken.None); } diff --git a/apps/gateway/Gateway.API.Tests/Errors/FhirErrorsTests.cs b/apps/gateway/Gateway.API.Tests/Errors/FhirErrorsTests.cs new file mode 100644 index 0000000..c1cbf92 --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Errors/FhirErrorsTests.cs @@ -0,0 +1,83 @@ +namespace Gateway.API.Tests.Errors; + +using Gateway.API.Abstractions; +using Gateway.API.Errors; + +public class FhirErrorsTests +{ + [Test] + public async Task ServiceUnavailable_HasCorrectCode() + { + await Assert.That(FhirErrors.ServiceUnavailable.Code).IsEqualTo("Fhir.ServiceUnavailable"); + } + + [Test] + public async Task ServiceUnavailable_HasCorrectType() + { + await Assert.That(FhirErrors.ServiceUnavailable.Type).IsEqualTo(ErrorType.Infrastructure); + } + + [Test] + public async Task Timeout_HasCorrectCode() + { + await Assert.That(FhirErrors.Timeout.Code).IsEqualTo("Fhir.Timeout"); + } + + [Test] + public async Task Timeout_HasCorrectType() + { + await Assert.That(FhirErrors.Timeout.Type).IsEqualTo(ErrorType.Infrastructure); + } + + [Test] + public async Task AuthenticationFailed_HasCorrectCode() + { + await Assert.That(FhirErrors.AuthenticationFailed.Code).IsEqualTo("Fhir.AuthFailed"); + } + + [Test] + public async Task AuthenticationFailed_HasCorrectType() + { + await Assert.That(FhirErrors.AuthenticationFailed.Type).IsEqualTo(ErrorType.Unauthorized); + } + + [Test] + public async Task NotFound_ReturnsCorrectError() + { + var error = FhirErrors.NotFound("Patient", "123"); + + await Assert.That(error.Code).IsEqualTo("Patient.NotFound"); + await Assert.That(error.Type).IsEqualTo(ErrorType.NotFound); + } + + [Test] + public async Task InvalidResponse_IncludesDetails() + { + var error = FhirErrors.InvalidResponse("missing resourceType"); + + await Assert.That(error.Code).IsEqualTo("Fhir.InvalidResponse"); + await Assert.That(error.Message).Contains("missing resourceType"); + await Assert.That(error.Type).IsEqualTo(ErrorType.Infrastructure); + } + + [Test] + public async Task NetworkError_WithoutInner_ReturnsCorrectError() + { + var error = FhirErrors.NetworkError("Connection failed"); + + await Assert.That(error.Code).IsEqualTo("Fhir.NetworkError"); + await Assert.That(error.Message).IsEqualTo("Connection failed"); + await Assert.That(error.Type).IsEqualTo(ErrorType.Infrastructure); + await Assert.That(error.Inner).IsNull(); + } + + [Test] + public async Task NetworkError_WithInner_IncludesInnerException() + { + var inner = new HttpRequestException("timeout"); + var error = FhirErrors.NetworkError("Connection failed", inner); + + await Assert.That(error.Inner).IsEqualTo(inner); + await Assert.That(error.Type).IsEqualTo(ErrorType.Infrastructure); + } +} diff --git a/apps/gateway/Gateway.API.Tests/Services/FhirClientTests.cs b/apps/gateway/Gateway.API.Tests/Services/FhirClientTests.cs new file mode 100644 index 0000000..a799561 --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Services/FhirClientTests.cs @@ -0,0 +1,168 @@ +using System.Text.Json; +using Gateway.API.Contracts; +using Gateway.API.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Gateway.API.Tests.Services; + +/// +/// Tests for FhirClient JSON extraction methods. +/// +public class FhirClientTests +{ + private readonly IFhirHttpClient _httpClient; + private readonly ILogger _logger; + private readonly FhirClient _sut; + + public FhirClientTests() + { + _httpClient = Substitute.For(); + _logger = Substitute.For>(); + _sut = new FhirClient(_httpClient, _logger); + } + + [Test] + public async Task SearchConditionsAsync_ExtractsClinicalStatus_FromCodeableConcept() + { + // Arrange + const string fhirBundle = """ + { + "resourceType": "Bundle", + "type": "searchset", + "entry": [ + { + "resource": { + "resourceType": "Condition", + "id": "cond-123", + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "E11.9", + "display": "Type 2 diabetes mellitus without complications" + } + ] + } + } + } + ] + } + """; + + var jsonDocument = JsonDocument.Parse(fhirBundle); + _httpClient.SearchAsync("Condition", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Success(jsonDocument.RootElement)); + + // Act + var conditions = await _sut.SearchConditionsAsync("patient-1", "token", CancellationToken.None); + + // Assert + await Assert.That(conditions.Count).IsEqualTo(1); + await Assert.That(conditions[0].ClinicalStatus).IsEqualTo("active"); + await Assert.That(conditions[0].Code).IsEqualTo("E11.9"); + } + + [Test] + public async Task SearchConditionsAsync_ReturnsNullClinicalStatus_WhenMissing() + { + // Arrange + const string fhirBundle = """ + { + "resourceType": "Bundle", + "type": "searchset", + "entry": [ + { + "resource": { + "resourceType": "Condition", + "id": "cond-456", + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "J06.9", + "display": "Acute upper respiratory infection" + } + ] + } + } + } + ] + } + """; + + var jsonDocument = JsonDocument.Parse(fhirBundle); + _httpClient.SearchAsync("Condition", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Success(jsonDocument.RootElement)); + + // Act + var conditions = await _sut.SearchConditionsAsync("patient-1", "token", CancellationToken.None); + + // Assert + await Assert.That(conditions.Count).IsEqualTo(1); + await Assert.That(conditions[0].ClinicalStatus).IsNull(); + await Assert.That(conditions[0].Code).IsEqualTo("J06.9"); + } + + [Test] + public async Task SearchConditionsAsync_HandlesMultipleClinicalStatuses() + { + // Arrange - FHIR allows multiple coding entries in clinicalStatus + const string fhirBundle = """ + { + "resourceType": "Bundle", + "type": "searchset", + "entry": [ + { + "resource": { + "resourceType": "Condition", + "id": "cond-789", + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "resolved", + "display": "Resolved" + }, + { + "system": "http://example.org/custom", + "code": "inactive" + } + ] + }, + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "195662009", + "display": "Acute viral pharyngitis" + } + ] + } + } + } + ] + } + """; + + var jsonDocument = JsonDocument.Parse(fhirBundle); + _httpClient.SearchAsync("Condition", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Success(jsonDocument.RootElement)); + + // Act + var conditions = await _sut.SearchConditionsAsync("patient-1", "token", CancellationToken.None); + + // Assert - should take the first coding entry + await Assert.That(conditions.Count).IsEqualTo(1); + await Assert.That(conditions[0].ClinicalStatus).IsEqualTo("resolved"); + } +} diff --git a/apps/gateway/Gateway.API/Abstractions/Error.cs b/apps/gateway/Gateway.API/Abstractions/Error.cs new file mode 100644 index 0000000..e732f16 --- /dev/null +++ b/apps/gateway/Gateway.API/Abstractions/Error.cs @@ -0,0 +1,6 @@ +namespace Gateway.API.Abstractions; + +public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Unexpected) +{ + public Exception? Inner { get; init; } +} diff --git a/apps/gateway/Gateway.API/Abstractions/ErrorFactory.cs b/apps/gateway/Gateway.API/Abstractions/ErrorFactory.cs new file mode 100644 index 0000000..ea18262 --- /dev/null +++ b/apps/gateway/Gateway.API/Abstractions/ErrorFactory.cs @@ -0,0 +1,50 @@ +namespace Gateway.API.Abstractions; + +/// +/// Factory methods for creating common error types. +/// +public static class ErrorFactory +{ + /// + /// Creates a NotFound error for a specific resource. + /// + /// The resource type (e.g., "Patient"). + /// The resource identifier. + /// A NotFound error. + public static Error NotFound(string resource, string id) + => new($"{resource}.NotFound", $"{resource}/{id} not found", ErrorType.NotFound); + + /// + /// Creates a validation error. + /// + /// The validation error message. + /// A Validation error. + public static Error Validation(string message) + => new("Validation.Failed", message, ErrorType.Validation); + + /// + /// Creates an unauthorized error. + /// + /// The error message. + /// An Unauthorized error. + public static Error Unauthorized(string message = "Authentication required") + => new("Auth.Unauthorized", message, ErrorType.Unauthorized); + + /// + /// Creates an infrastructure error. + /// + /// The error message. + /// The inner exception, if any. + /// An Infrastructure error. + public static Error Infrastructure(string message, Exception? inner = null) + => new("Infrastructure.Error", message, ErrorType.Infrastructure) { Inner = inner }; + + /// + /// Creates an unexpected error. + /// + /// The error message. + /// The inner exception, if any. + /// An Unexpected error. + public static Error Unexpected(string message, Exception? inner = null) + => new("Unexpected.Error", message, ErrorType.Unexpected) { Inner = inner }; +} diff --git a/apps/gateway/Gateway.API/Abstractions/ErrorType.cs b/apps/gateway/Gateway.API/Abstractions/ErrorType.cs new file mode 100644 index 0000000..380058c --- /dev/null +++ b/apps/gateway/Gateway.API/Abstractions/ErrorType.cs @@ -0,0 +1,13 @@ +namespace Gateway.API.Abstractions; + +public enum ErrorType +{ + None = 0, + NotFound = 404, + Validation = 400, + Conflict = 409, + Unauthorized = 401, + Forbidden = 403, + Infrastructure = 503, + Unexpected = 500 +} diff --git a/apps/gateway/Gateway.API/Abstractions/Result.cs b/apps/gateway/Gateway.API/Abstractions/Result.cs new file mode 100644 index 0000000..365eb27 --- /dev/null +++ b/apps/gateway/Gateway.API/Abstractions/Result.cs @@ -0,0 +1,35 @@ +namespace Gateway.API.Abstractions; + +public readonly record struct Result +{ + public T? Value { get; } + public Error? Error { get; } + public bool IsSuccess => Error is null; + public bool IsFailure => !IsSuccess; + + private Result(T value) + { + Value = value; + Error = null; + } + + private Result(Error error) + { + Value = default; + Error = error; + } + + public static Result Success(T value) => new(value); + public static Result Failure(Error error) => new(error); + + public TResult Match( + Func onSuccess, + Func onFailure) + => IsSuccess ? onSuccess(Value!) : onFailure(Error!); + + public Result Map(Func mapper) + => IsSuccess ? Result.Success(mapper(Value!)) : Result.Failure(Error!); + + public static implicit operator Result(T value) => Success(value); + public static implicit operator Result(Error error) => Failure(error); +} diff --git a/apps/gateway/Gateway.API/Configuration/CachingSettings.cs b/apps/gateway/Gateway.API/Configuration/CachingSettings.cs new file mode 100644 index 0000000..48d8113 --- /dev/null +++ b/apps/gateway/Gateway.API/Configuration/CachingSettings.cs @@ -0,0 +1,38 @@ +namespace Gateway.API.Configuration; + +/// +/// Configuration settings for caching behavior. +/// +public sealed class CachingSettings +{ + /// + /// Configuration section name. + /// + public const string SectionName = "Caching"; + + /// + /// Gets or sets whether caching is enabled. + /// + public bool Enabled { get; init; } = true; + + /// + /// Gets or sets the cache duration. + /// + public TimeSpan Duration { get; init; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the local (L1) cache duration. + /// + public TimeSpan LocalCacheDuration { get; init; } = TimeSpan.FromMinutes(1); + + /// + /// Gets or sets the cache key prefix. + /// + public string KeyPrefix { get; init; } = "authscript"; + + /// + /// Validates the settings configuration. + /// + /// True if valid, false otherwise. + public bool IsValid() => Duration > TimeSpan.Zero; +} diff --git a/apps/gateway/Gateway.API/Configuration/ClinicalQueryOptions.cs b/apps/gateway/Gateway.API/Configuration/ClinicalQueryOptions.cs new file mode 100644 index 0000000..560ac2c --- /dev/null +++ b/apps/gateway/Gateway.API/Configuration/ClinicalQueryOptions.cs @@ -0,0 +1,28 @@ +namespace Gateway.API.Configuration; + +/// +/// Configuration options for clinical FHIR queries. +/// +public sealed class ClinicalQueryOptions +{ + /// + /// Configuration section name. + /// + public const string SectionName = "ClinicalQuery"; + + /// + /// Gets or sets the lookback period in months for observations. + /// + public int ObservationLookbackMonths { get; init; } = 6; + + /// + /// Gets or sets the lookback period in months for procedures. + /// + public int ProcedureLookbackMonths { get; init; } = 12; + + /// + /// Validates the options configuration. + /// + /// True if valid, false otherwise. + public bool IsValid() => ObservationLookbackMonths > 0 && ProcedureLookbackMonths > 0; +} diff --git a/apps/gateway/Gateway.API/Configuration/DocumentOptions.cs b/apps/gateway/Gateway.API/Configuration/DocumentOptions.cs new file mode 100644 index 0000000..0e7acda --- /dev/null +++ b/apps/gateway/Gateway.API/Configuration/DocumentOptions.cs @@ -0,0 +1,28 @@ +namespace Gateway.API.Configuration; + +/// +/// Configuration options for document operations. +/// +public sealed class DocumentOptions +{ + /// + /// Configuration section name. + /// + public const string SectionName = "Document"; + + /// + /// LOINC code for prior authorization documents. + /// + public string PriorAuthLoincCode { get; init; } = "64289-6"; + + /// + /// Display name for prior authorization LOINC code. + /// + public string PriorAuthLoincDisplay { get; init; } = "Prior authorization request"; + + /// + /// Validates the options configuration. + /// + /// True if valid, false otherwise. + public bool IsValid() => !string.IsNullOrWhiteSpace(PriorAuthLoincCode); +} diff --git a/apps/gateway/Gateway.API/Configuration/EpicFhirOptions.cs b/apps/gateway/Gateway.API/Configuration/EpicFhirOptions.cs new file mode 100644 index 0000000..9261aee --- /dev/null +++ b/apps/gateway/Gateway.API/Configuration/EpicFhirOptions.cs @@ -0,0 +1,32 @@ +namespace Gateway.API.Configuration; + +/// +/// Configuration for Epic FHIR API connectivity. +/// +public sealed class EpicFhirOptions +{ + /// + /// Configuration section name. + /// + public const string SectionName = "Epic"; + + /// + /// Base URL for Epic FHIR R4 API. + /// + public required string FhirBaseUrl { get; init; } + + /// + /// OAuth client ID for Epic. + /// + public required string ClientId { get; init; } + + /// + /// OAuth client secret (from user-secrets in dev). + /// + public string? ClientSecret { get; init; } + + /// + /// Token endpoint for client credentials flow. + /// + public string? TokenEndpoint { get; init; } +} diff --git a/apps/gateway/Gateway.API/Configuration/IntelligenceOptions.cs b/apps/gateway/Gateway.API/Configuration/IntelligenceOptions.cs new file mode 100644 index 0000000..a91f81d --- /dev/null +++ b/apps/gateway/Gateway.API/Configuration/IntelligenceOptions.cs @@ -0,0 +1,22 @@ +namespace Gateway.API.Configuration; + +/// +/// Configuration for Intelligence service connectivity. +/// +public sealed class IntelligenceOptions +{ + /// + /// Configuration section name. + /// + public const string SectionName = "Intelligence"; + + /// + /// Base URL for Intelligence API. + /// + public required string BaseUrl { get; init; } + + /// + /// Request timeout in seconds. + /// + public int TimeoutSeconds { get; init; } = 30; +} diff --git a/apps/gateway/Gateway.API/Configuration/ResiliencyOptions.cs b/apps/gateway/Gateway.API/Configuration/ResiliencyOptions.cs new file mode 100644 index 0000000..6045890 --- /dev/null +++ b/apps/gateway/Gateway.API/Configuration/ResiliencyOptions.cs @@ -0,0 +1,37 @@ +namespace Gateway.API.Configuration; + +/// +/// Configuration for HTTP resilience policies. +/// +public sealed class ResiliencyOptions +{ + /// + /// Configuration section name. + /// + public const string SectionName = "Resilience"; + + /// + /// Maximum retry attempts. + /// + public int MaxRetryAttempts { get; init; } = 3; + + /// + /// Base delay between retries in seconds. + /// + public double RetryDelaySeconds { get; init; } = 1.0; + + /// + /// Request timeout in seconds. + /// + public int TimeoutSeconds { get; init; } = 10; + + /// + /// Circuit breaker failure threshold. + /// + public int CircuitBreakerThreshold { get; init; } = 5; + + /// + /// Circuit breaker break duration in seconds. + /// + public int CircuitBreakerDurationSeconds { get; init; } = 30; +} diff --git a/apps/gateway/Gateway.API/Contracts/IDemoCacheService.cs b/apps/gateway/Gateway.API/Contracts/IAnalysisResultStore.cs similarity index 91% rename from apps/gateway/Gateway.API/Contracts/IDemoCacheService.cs rename to apps/gateway/Gateway.API/Contracts/IAnalysisResultStore.cs index 19654fa..fa61c62 100644 --- a/apps/gateway/Gateway.API/Contracts/IDemoCacheService.cs +++ b/apps/gateway/Gateway.API/Contracts/IAnalysisResultStore.cs @@ -3,9 +3,10 @@ namespace Gateway.API.Contracts; /// -/// Caching service for demo mode to reduce redundant Intelligence service calls. +/// Stores and retrieves completed analysis results. +/// Used for caching analysis responses and generated PDFs by transaction ID. /// -public interface IDemoCacheService +public interface IAnalysisResultStore { /// /// Retrieves a cached PA form data response. diff --git a/apps/gateway/Gateway.API/Contracts/IEpicUploader.cs b/apps/gateway/Gateway.API/Contracts/IDocumentUploader.cs similarity index 54% rename from apps/gateway/Gateway.API/Contracts/IEpicUploader.cs rename to apps/gateway/Gateway.API/Contracts/IDocumentUploader.cs index d966a82..7fcd8bc 100644 --- a/apps/gateway/Gateway.API/Contracts/IEpicUploader.cs +++ b/apps/gateway/Gateway.API/Contracts/IDocumentUploader.cs @@ -1,21 +1,20 @@ namespace Gateway.API.Contracts; /// -/// Uploads completed PA forms to Epic as FHIR DocumentReference resources. +/// Interface for uploading documents to a FHIR server. /// -public interface IEpicUploader +public interface IDocumentUploader { /// - /// Uploads a PDF document to Epic's FHIR server as a DocumentReference. + /// Uploads a PDF document as a FHIR DocumentReference resource. /// - /// The PDF document content as a byte array. + /// The PDF content to upload. /// The FHIR Patient resource ID. /// Optional FHIR Encounter resource ID for context. /// OAuth access token for authentication. /// Cancellation token. - /// The FHIR DocumentReference resource ID of the uploaded document. - /// When the upload fails. - Task UploadDocumentAsync( + /// The created DocumentReference resource ID, or an error. + Task> UploadDocumentAsync( byte[] pdfBytes, string patientId, string? encounterId, diff --git a/apps/gateway/Gateway.API/Contracts/IEpicFhirClient.cs b/apps/gateway/Gateway.API/Contracts/IFhirClient.cs similarity index 96% rename from apps/gateway/Gateway.API/Contracts/IEpicFhirClient.cs rename to apps/gateway/Gateway.API/Contracts/IFhirClient.cs index 9b58ad4..7cbfcf7 100644 --- a/apps/gateway/Gateway.API/Contracts/IEpicFhirClient.cs +++ b/apps/gateway/Gateway.API/Contracts/IFhirClient.cs @@ -3,9 +3,10 @@ namespace Gateway.API.Contracts; /// -/// Client for interacting with Epic's FHIR R4 API to retrieve clinical data. +/// High-level client for FHIR R4 API operations. +/// Provides domain-specific methods for retrieving clinical data. /// -public interface IEpicFhirClient +public interface IFhirClient { /// /// Retrieves patient demographic information. diff --git a/apps/gateway/Gateway.API/Contracts/IFhirHttpClient.cs b/apps/gateway/Gateway.API/Contracts/IFhirHttpClient.cs new file mode 100644 index 0000000..8d96866 --- /dev/null +++ b/apps/gateway/Gateway.API/Contracts/IFhirHttpClient.cs @@ -0,0 +1,64 @@ +using System.Text.Json; + +namespace Gateway.API.Contracts; + +/// +/// Low-level HTTP interface for FHIR server operations. +/// Handles authentication and HTTP transport, returning raw JSON responses. +/// +public interface IFhirHttpClient +{ + /// + /// Reads a single FHIR resource by ID. + /// + /// The FHIR resource type (e.g., "Patient", "Condition"). + /// The resource ID. + /// OAuth access token for authentication. + /// Cancellation token. + /// The raw JSON resource or an error. + Task> ReadAsync( + string resourceType, + string id, + string accessToken, + CancellationToken ct = default); + + /// + /// Searches for FHIR resources matching the query. + /// + /// The FHIR resource type. + /// The FHIR search query string. + /// OAuth access token for authentication. + /// Cancellation token. + /// The raw JSON bundle or an error. + Task> SearchAsync( + string resourceType, + string query, + string accessToken, + CancellationToken ct = default); + + /// + /// Creates a new FHIR resource. + /// + /// The FHIR resource type. + /// The resource JSON to create. + /// OAuth access token for authentication. + /// Cancellation token. + /// The created resource JSON with server-assigned ID, or an error. + Task> CreateAsync( + string resourceType, + string resourceJson, + string accessToken, + CancellationToken ct = default); + + /// + /// Reads binary content by ID. + /// + /// The Binary resource ID. + /// OAuth access token for authentication. + /// Cancellation token. + /// The binary content or an error. + Task> ReadBinaryAsync( + string id, + string accessToken, + CancellationToken ct = default); +} diff --git a/apps/gateway/Gateway.API/Contracts/Result.cs b/apps/gateway/Gateway.API/Contracts/Result.cs index 8199fdb..9c4ff6d 100644 --- a/apps/gateway/Gateway.API/Contracts/Result.cs +++ b/apps/gateway/Gateway.API/Contracts/Result.cs @@ -104,4 +104,12 @@ public static FhirError Network(string message, Exception? inner = null) /// A validation error. public static FhirError Validation(string message) => new("VALIDATION_ERROR", message); + + /// + /// Creates an invalid response error. + /// + /// The error message describing the invalid response. + /// An invalid response error. + public static FhirError InvalidResponse(string message) + => new("INVALID_RESPONSE", message); } diff --git a/apps/gateway/Gateway.API/DependencyExtensions.cs b/apps/gateway/Gateway.API/DependencyExtensions.cs new file mode 100644 index 0000000..45524ac --- /dev/null +++ b/apps/gateway/Gateway.API/DependencyExtensions.cs @@ -0,0 +1,111 @@ +using Gateway.API.Configuration; +using Gateway.API.Contracts; +using Gateway.API.Services; +using Gateway.API.Services.Decorators; +using Gateway.API.Services.Fhir; +using Microsoft.Extensions.Caching.Hybrid; + +namespace Gateway.API; + +/// +/// Extension methods for configuring Gateway services. +/// +public static class DependencyExtensions +{ + /// + /// Adds Gateway services to the dependency injection container. + /// + /// The service collection. + /// The configuration. + /// The service collection for chaining. + public static IServiceCollection AddGatewayServices(this IServiceCollection services, IConfiguration configuration) + { + // Configuration options with validation + services.AddOptions() + .Bind(configuration.GetSection(ClinicalQueryOptions.SectionName)) + .Validate(o => o.IsValid(), "ClinicalQueryOptions validation failed"); + + services.AddOptions() + .Bind(configuration.GetSection(Configuration.DocumentOptions.SectionName)) + .Validate(o => o.IsValid(), "DocumentOptions validation failed"); + + services.AddOptions() + .Bind(configuration.GetSection(CachingSettings.SectionName)) + .Validate(o => o.IsValid(), "CachingSettings validation failed"); + + // HybridCache for two-tier caching (L1 in-memory + L2 Redis) + var cachingSettings = configuration.GetSection(CachingSettings.SectionName) + .Get() ?? new CachingSettings(); + services.AddHybridCache(options => + { + options.DefaultEntryOptions = new HybridCacheEntryOptions + { + Expiration = cachingSettings.Duration, + LocalCacheExpiration = cachingSettings.LocalCacheDuration + }; + }); + + // Application services + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + + return services; + } + + /// + /// Adds FHIR HTTP clients to the dependency injection container. + /// + /// The service collection. + /// The configuration. + /// The service collection for chaining. + public static IServiceCollection AddFhirClients(this IServiceCollection services, IConfiguration configuration) + { + var fhirBaseUrl = configuration["Epic:FhirBaseUrl"]; + if (string.IsNullOrWhiteSpace(fhirBaseUrl)) + { + throw new InvalidOperationException("Epic:FhirBaseUrl must be configured."); + } + + // Low-level FHIR HTTP client + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(fhirBaseUrl); + }); + + // High-level FHIR client (uses IFhirHttpClient) + services.AddScoped(); + + // Document uploader (uses IFhirHttpClient) + services.AddScoped(); + + return services; + } + + /// + /// Adds the Intelligence client to the dependency injection container. + /// Optionally wraps with caching decorator based on configuration. + /// + /// + /// STUB: Currently registers a stub implementation that returns mock data. + /// Production will add HttpClient configuration for the Intelligence service. + /// + /// The service collection. + /// The configuration. + /// The service collection for chaining. + public static IServiceCollection AddIntelligenceClient(this IServiceCollection services, IConfiguration configuration) + { + // STUB: Register stub implementation without HTTP client + // Production will use: services.AddHttpClient(...) + services.AddScoped(); + + // Apply caching decorator if enabled + var cachingSettings = configuration.GetSection(CachingSettings.SectionName).Get(); + if (cachingSettings?.Enabled == true) + { + services.Decorate(); + } + + return services; + } +} diff --git a/apps/gateway/Gateway.API/Endpoints/AnalysisEndpoints.cs b/apps/gateway/Gateway.API/Endpoints/AnalysisEndpoints.cs index 26e3c31..d8d7bfd 100644 --- a/apps/gateway/Gateway.API/Endpoints/AnalysisEndpoints.cs +++ b/apps/gateway/Gateway.API/Endpoints/AnalysisEndpoints.cs @@ -36,9 +36,9 @@ public static void MapAnalysisEndpoints(this IEndpointRouteBuilder app) .Produces(StatusCodes.Status200OK, contentType: "application/pdf") .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{transactionId}/submit", SubmitToEpic) - .WithName("SubmitToEpic") - .WithSummary("Submit the PA form to Epic (manual fallback)") + group.MapPost("/{transactionId}/submit", SubmitToFhir) + .WithName("SubmitToFhir") + .WithSummary("Submit the PA form to FHIR server (manual fallback)") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status500InternalServerError); @@ -50,25 +50,25 @@ public static void MapAnalysisEndpoints(this IEndpointRouteBuilder app) private static async Task GetAnalysis( string transactionId, - [FromServices] IDemoCacheService cacheService, + [FromServices] IAnalysisResultStore resultStore, CancellationToken cancellationToken) { - return await GetAnalysisAsync(transactionId, cacheService, cancellationToken); + return await GetAnalysisAsync(transactionId, resultStore, cancellationToken); } /// /// Gets the analysis result for a given transaction ID. /// /// The transaction identifier. - /// The cache service. + /// The analysis result store. /// Cancellation token. /// The analysis response or 404 if not found. public static async Task GetAnalysisAsync( string transactionId, - IDemoCacheService cacheService, + IAnalysisResultStore resultStore, CancellationToken cancellationToken) { - var formData = await cacheService.GetCachedResponseAsync(transactionId, cancellationToken); + var formData = await resultStore.GetCachedResponseAsync(transactionId, cancellationToken); if (formData is null) { @@ -90,25 +90,25 @@ public static async Task GetAnalysisAsync( private static async Task GetAnalysisStatus( string transactionId, - [FromServices] IDemoCacheService cacheService, + [FromServices] IAnalysisResultStore resultStore, CancellationToken cancellationToken) { - return await GetAnalysisStatusAsync(transactionId, cacheService, cancellationToken); + return await GetAnalysisStatusAsync(transactionId, resultStore, cancellationToken); } /// /// Gets the current status of an analysis. /// /// The transaction identifier. - /// The cache service. + /// The analysis result store. /// Cancellation token. /// The status response. public static async Task GetAnalysisStatusAsync( string transactionId, - IDemoCacheService cacheService, + IAnalysisResultStore resultStore, CancellationToken cancellationToken) { - var formData = await cacheService.GetCachedResponseAsync(transactionId, cancellationToken); + var formData = await resultStore.GetCachedResponseAsync(transactionId, cancellationToken); if (formData is not null) { @@ -133,29 +133,29 @@ public static async Task GetAnalysisStatusAsync( private static async Task DownloadForm( string transactionId, - [FromServices] IDemoCacheService cacheService, + [FromServices] IAnalysisResultStore resultStore, [FromServices] IPdfFormStamper pdfStamper, CancellationToken cancellationToken) { - return await DownloadFormAsync(transactionId, cacheService, pdfStamper, cancellationToken); + return await DownloadFormAsync(transactionId, resultStore, pdfStamper, cancellationToken); } /// /// Downloads the generated PA form PDF. /// /// The transaction identifier. - /// The cache service. + /// The analysis result store. /// The PDF stamper service. /// Cancellation token. /// The PDF file or 404 if not found. public static async Task DownloadFormAsync( string transactionId, - IDemoCacheService cacheService, + IAnalysisResultStore resultStore, IPdfFormStamper pdfStamper, CancellationToken cancellationToken) { // First, try to get cached PDF - var cachedPdf = await cacheService.GetCachedPdfAsync(transactionId, cancellationToken); + var cachedPdf = await resultStore.GetCachedPdfAsync(transactionId, cancellationToken); if (cachedPdf is not null) { @@ -166,7 +166,7 @@ public static async Task DownloadFormAsync( } // No cached PDF, try to generate from form data - var formData = await cacheService.GetCachedResponseAsync(transactionId, cancellationToken); + var formData = await resultStore.GetCachedResponseAsync(transactionId, cancellationToken); if (formData is null) { @@ -179,7 +179,7 @@ public static async Task DownloadFormAsync( // Generate PDF and cache it var pdfBytes = await pdfStamper.StampFormAsync(formData, cancellationToken); - await cacheService.SetCachedPdfAsync(transactionId, pdfBytes, cancellationToken); + await resultStore.SetCachedPdfAsync(transactionId, pdfBytes, cancellationToken); return Results.File( pdfBytes, @@ -187,47 +187,47 @@ public static async Task DownloadFormAsync( $"pa-form-{transactionId}.pdf"); } - private static async Task SubmitToEpic( + private static async Task SubmitToFhir( string transactionId, - [FromBody] SubmitToEpicRequest request, - [FromServices] IEpicUploader epicUploader, - [FromServices] IDemoCacheService cacheService, + [FromBody] SubmitToFhirRequest request, + [FromServices] IDocumentUploader documentUploader, + [FromServices] IAnalysisResultStore resultStore, [FromServices] IPdfFormStamper pdfStamper, CancellationToken cancellationToken) { - return await SubmitToEpicAsync( + return await SubmitToFhirAsync( transactionId, request, - epicUploader, - cacheService, + documentUploader, + resultStore, pdfStamper, cancellationToken); } /// - /// Submits the PA form to Epic as a DocumentReference. + /// Submits the PA form to FHIR server as a DocumentReference. /// /// The transaction identifier. - /// The submission request with Epic credentials. - /// The Epic uploader service. - /// The cache service. + /// The submission request with credentials. + /// The document uploader service. + /// The analysis result store. /// The PDF stamper service. /// Cancellation token. /// The submission response. - public static async Task SubmitToEpicAsync( + public static async Task SubmitToFhirAsync( string transactionId, - SubmitToEpicRequest request, - IEpicUploader epicUploader, - IDemoCacheService cacheService, + SubmitToFhirRequest request, + IDocumentUploader documentUploader, + IAnalysisResultStore resultStore, IPdfFormStamper pdfStamper, CancellationToken cancellationToken) { // Get the PDF (from cache or generate) - var pdfBytes = await cacheService.GetCachedPdfAsync(transactionId, cancellationToken); + var pdfBytes = await resultStore.GetCachedPdfAsync(transactionId, cancellationToken); if (pdfBytes is null) { - var formData = await cacheService.GetCachedResponseAsync(transactionId, cancellationToken); + var formData = await resultStore.GetCachedResponseAsync(transactionId, cancellationToken); if (formData is null) { @@ -239,33 +239,31 @@ public static async Task SubmitToEpicAsync( } pdfBytes = await pdfStamper.StampFormAsync(formData, cancellationToken); - await cacheService.SetCachedPdfAsync(transactionId, pdfBytes, cancellationToken); + await resultStore.SetCachedPdfAsync(transactionId, pdfBytes, cancellationToken); } - try - { - var documentId = await epicUploader.UploadDocumentAsync( - pdfBytes, - request.PatientId, - request.EncounterId, - request.AccessToken, - cancellationToken); - - return Results.Ok(new SubmitResponse - { - TransactionId = transactionId, - Submitted = true, - DocumentId = documentId, - Message = "PA form successfully submitted to Epic" - }); - } - catch (HttpRequestException ex) + var result = await documentUploader.UploadDocumentAsync( + pdfBytes, + request.PatientId, + request.EncounterId, + request.AccessToken, + cancellationToken); + + if (result.IsFailure) { return Results.Problem( - detail: ex.Message, - title: "Epic Submission Failed", + detail: result.Error?.Message, + title: "FHIR Submission Failed", statusCode: StatusCodes.Status500InternalServerError); } + + return Results.Ok(new SubmitResponse + { + TransactionId = transactionId, + Submitted = true, + DocumentId = result.Value!, + Message = "PA form successfully submitted to FHIR server" + }); } private static async Task TriggerAnalysis( diff --git a/apps/gateway/Gateway.API/Endpoints/CdsHooksEndpoints.cs b/apps/gateway/Gateway.API/Endpoints/CdsHooksEndpoints.cs deleted file mode 100644 index a37ec9d..0000000 --- a/apps/gateway/Gateway.API/Endpoints/CdsHooksEndpoints.cs +++ /dev/null @@ -1,325 +0,0 @@ -using Gateway.API.Contracts; -using Gateway.API.Models; -using Gateway.API.Services; -using Microsoft.AspNetCore.Mvc; - -namespace Gateway.API.Endpoints; - -public static class CdsHooksEndpoints -{ - // MRI Lumbar CPT codes we handle - private static readonly HashSet SupportedProcedureCodes = ["72148", "72149", "72158"]; - - public static void MapCdsHooksEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup("/cds-services") - .WithTags("CDS Hooks"); - - // Discovery endpoint - Epic registers this - group.MapGet("/", GetDiscoveryDocument) - .WithName("GetCdsServices") - .WithSummary("CDS Hooks discovery endpoint"); - - // Individual service discovery - group.MapGet("/authscript", GetServiceDefinition) - .WithName("GetAuthScriptService") - .WithSummary("AuthScript service definition"); - - // Order-select hook endpoint - group.MapPost("/authscript", HandleOrderSelect) - .WithName("HandleOrderSelect") - .WithSummary("Handle order-select CDS Hook from Epic"); - } - - private static IResult GetDiscoveryDocument() - { - var discovery = new - { - services = new[] - { - new - { - id = "authscript", - hook = "order-select", - title = "AuthScript Prior Authorization", - description = "AI-powered prior authorization form completion for MRI Lumbar Spine", - prefetch = new - { - patient = "Patient/{{context.patientId}}", - serviceRequest = "ServiceRequest?_id={{context.draftOrders.ServiceRequest.id}}" - } - } - } - }; - - return Results.Ok(discovery); - } - - private static IResult GetServiceDefinition() - { - var service = new - { - id = "authscript", - hook = "order-select", - title = "AuthScript Prior Authorization", - description = "AI-powered prior authorization form completion for MRI Lumbar Spine", - prefetch = new - { - patient = "Patient/{{context.patientId}}", - serviceRequest = "ServiceRequest?_id={{context.draftOrders.ServiceRequest.id}}" - } - }; - - return Results.Ok(service); - } - - private static async Task HandleOrderSelect( - [FromBody] CdsRequest request, - [FromServices] IFhirDataAggregator fhirAggregator, - [FromServices] IIntelligenceClient intelligenceClient, - [FromServices] IPdfFormStamper pdfStamper, - [FromServices] IEpicUploader epicUploader, - [FromServices] IDemoCacheService cacheService, - [FromServices] IConfiguration config, - [FromServices] ILogger logger, - CancellationToken cancellationToken) - { - var transactionId = $"txn-{Guid.NewGuid():N}"; - - logger.LogInformation( - "Received order-select hook. TransactionId={TransactionId}, PatientId={PatientId}", - transactionId, request.Context.PatientId); - - // Check if this is a procedure we handle - var procedureCode = ExtractProcedureCode(request); - if (procedureCode is null || !SupportedProcedureCodes.Contains(procedureCode)) - { - logger.LogInformation("Procedure code {Code} not supported, returning empty cards", procedureCode); - return Results.Ok(new CdsResponse { Cards = [] }); - } - - // Set up timeout for CDS Hook response (Epic expects <10 seconds) - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(8)); - - try - { - // Check cache first (for demo scenarios) - var cacheKey = $"{request.Context.PatientId}:{procedureCode}"; - var cachedResponse = await cacheService.GetCachedResponseAsync(cacheKey, cts.Token); - if (cachedResponse is not null) - { - logger.LogInformation("Cache hit for {CacheKey}", cacheKey); - return Results.Ok(BuildSuccessCard(transactionId, cachedResponse, config)); - } - - // Full pipeline - var accessToken = request.FhirAuthorization?.AccessToken; - if (string.IsNullOrEmpty(accessToken)) - { - logger.LogWarning("No access token provided in CDS request"); - return Results.Ok(BuildErrorCard("Missing FHIR authorization")); - } - - // 1. Aggregate FHIR data - var clinicalBundle = await fhirAggregator.AggregateClinicalDataAsync( - request.Context.PatientId, - accessToken, - cts.Token); - - // 2. Send to Intelligence service for analysis - var formData = await intelligenceClient.AnalyzeAsync( - clinicalBundle, - procedureCode, - cts.Token); - - // 3. Stamp PDF form - var pdfBytes = await pdfStamper.StampFormAsync(formData, cts.Token); - - // 4. Upload to Epic - var documentId = await epicUploader.UploadDocumentAsync( - pdfBytes, - request.Context.PatientId, - request.Context.EncounterId, - accessToken, - cts.Token); - - // Cache the successful response for demo purposes - await cacheService.SetCachedResponseAsync(cacheKey, formData, cts.Token); - - logger.LogInformation( - "PA form generated successfully. TransactionId={TransactionId}, DocumentId={DocumentId}", - transactionId, documentId); - - return Results.Ok(BuildSuccessCard(transactionId, formData, config, documentId)); - } - catch (OperationCanceledException) - { - logger.LogWarning("Pipeline timeout for TransactionId={TransactionId}", transactionId); - return Results.Ok(BuildProcessingCard(transactionId, config)); - } - catch (Exception ex) - { - logger.LogError(ex, "Pipeline error for TransactionId={TransactionId}", transactionId); - return Results.Ok(BuildFallbackCard(transactionId, config)); - } - } - - private static string? ExtractProcedureCode(CdsRequest request) - { - var entries = request.Context.DraftOrders?.Entry; - if (entries is null) return null; - - foreach (var entry in entries) - { - var codings = entry.Resource?.Code?.Coding; - if (codings is null) continue; - - foreach (var coding in codings) - { - if (coding.System?.Contains("cpt", StringComparison.OrdinalIgnoreCase) == true - || string.IsNullOrEmpty(coding.System)) - { - if (!string.IsNullOrEmpty(coding.Code)) - return coding.Code; - } - } - } - - return null; - } - - private static CdsResponse BuildSuccessCard( - string transactionId, - PAFormData formData, - IConfiguration config, - string? documentId = null) - { - var dashboardUrl = config["Dashboard:BaseUrl"] ?? "http://localhost:5173"; - var confidencePercent = (int)(formData.ConfidenceScore * 100); - - return new CdsResponse - { - Cards = - [ - new CdsCard - { - Uuid = transactionId, - Summary = "Prior Authorization Form Ready", - Detail = $"AuthScript has completed the PA form for MRI Lumbar Spine. " + - $"Confidence: {confidencePercent}%. Recommendation: {formData.Recommendation}", - Indicator = formData.Recommendation == "APPROVE" ? "info" : "warning", - Source = new CdsSource - { - Label = "AuthScript", - Url = dashboardUrl - }, - Suggestions = documentId is not null - ? - [ - new CdsSuggestion - { - Label = "Review Form", - Uuid = $"suggestion-{transactionId}", - IsRecommended = true, - Actions = - [ - new CdsAction - { - Type = "create", - Description = "Open completed PA form", - Resource = new { resourceType = "DocumentReference", id = documentId } - } - ] - } - ] - : null, - Links = - [ - new CdsLink - { - Label = "View in AuthScript Dashboard", - Url = $"{dashboardUrl}/analysis/{transactionId}", - Type = "absolute" - } - ] - } - ] - }; - } - - private static CdsResponse BuildProcessingCard(string transactionId, IConfiguration config) - { - var dashboardUrl = config["Dashboard:BaseUrl"] ?? "http://localhost:5173"; - - return new CdsResponse - { - Cards = - [ - new CdsCard - { - Uuid = transactionId, - Summary = "Processing Prior Authorization...", - Detail = "AuthScript is analyzing the clinical data. Check the dashboard for real-time status.", - Indicator = "info", - Source = new CdsSource { Label = "AuthScript", Url = dashboardUrl }, - Links = - [ - new CdsLink - { - Label = "View Progress", - Url = $"{dashboardUrl}/analysis/{transactionId}", - Type = "absolute" - } - ] - } - ] - }; - } - - private static CdsResponse BuildFallbackCard(string transactionId, IConfiguration config) - { - var dashboardUrl = config["Dashboard:BaseUrl"] ?? "http://localhost:5173"; - - return new CdsResponse - { - Cards = - [ - new CdsCard - { - Uuid = transactionId, - Summary = "Launch AuthScript", - Detail = "Automated analysis encountered an issue. Launch AuthScript to complete the PA form manually.", - Indicator = "warning", - Source = new CdsSource { Label = "AuthScript", Url = dashboardUrl }, - Links = - [ - new CdsLink - { - Label = "Launch AuthScript App", - Url = $"{dashboardUrl}/smart-launch?transaction={transactionId}", - Type = "smart" - } - ] - } - ] - }; - } - - private static CdsResponse BuildErrorCard(string message) - { - return new CdsResponse - { - Cards = - [ - new CdsCard - { - Summary = "AuthScript Error", - Detail = message, - Indicator = "critical", - Source = new CdsSource { Label = "AuthScript" } - } - ] - }; - } -} diff --git a/apps/gateway/Gateway.API/Errors/FhirErrors.cs b/apps/gateway/Gateway.API/Errors/FhirErrors.cs new file mode 100644 index 0000000..f6375ae --- /dev/null +++ b/apps/gateway/Gateway.API/Errors/FhirErrors.cs @@ -0,0 +1,53 @@ +namespace Gateway.API.Errors; + +using Gateway.API.Abstractions; + +/// +/// Domain-specific errors for FHIR operations. +/// +public static class FhirErrors +{ + /// + /// FHIR service is unavailable. + /// + public static readonly Error ServiceUnavailable = + new("Fhir.ServiceUnavailable", "FHIR service is unavailable", ErrorType.Infrastructure); + + /// + /// FHIR request timed out. + /// + public static readonly Error Timeout = + new("Fhir.Timeout", "FHIR request timed out", ErrorType.Infrastructure); + + /// + /// Failed to authenticate with FHIR server. + /// + public static readonly Error AuthenticationFailed = + new("Fhir.AuthFailed", "Failed to authenticate with FHIR server", ErrorType.Unauthorized); + + /// + /// Creates a NotFound error for a FHIR resource. + /// + /// The FHIR resource type (e.g., "Patient"). + /// The resource identifier. + /// A NotFound error. + public static Error NotFound(string resourceType, string id) => + ErrorFactory.NotFound(resourceType, id); + + /// + /// Creates an error for invalid FHIR response. + /// + /// Details about why the response is invalid. + /// An InvalidResponse error. + public static Error InvalidResponse(string details) => + new("Fhir.InvalidResponse", $"Invalid FHIR response: {details}", ErrorType.Infrastructure); + + /// + /// Creates an error for network issues when communicating with FHIR server. + /// + /// The error message. + /// The inner exception, if any. + /// A NetworkError. + public static Error NetworkError(string message, Exception? inner = null) => + new("Fhir.NetworkError", message, ErrorType.Infrastructure) { Inner = inner }; +} diff --git a/apps/gateway/Gateway.API/Gateway.API.csproj b/apps/gateway/Gateway.API/Gateway.API.csproj index 912904c..a4c45be 100644 --- a/apps/gateway/Gateway.API/Gateway.API.csproj +++ b/apps/gateway/Gateway.API/Gateway.API.csproj @@ -21,6 +21,10 @@ + + + + diff --git a/apps/gateway/Gateway.API/Models/AnalysisResponses.cs b/apps/gateway/Gateway.API/Models/AnalysisResponses.cs index 2493c30..86d2377 100644 --- a/apps/gateway/Gateway.API/Models/AnalysisResponses.cs +++ b/apps/gateway/Gateway.API/Models/AnalysisResponses.cs @@ -63,7 +63,7 @@ public sealed record StatusResponse } /// -/// Response for the SubmitToEpic endpoint. +/// Response for the SubmitToFhir endpoint. /// public sealed record SubmitResponse { @@ -78,7 +78,7 @@ public sealed record SubmitResponse public required bool Submitted { get; init; } /// - /// Gets the Epic DocumentReference ID when submitted successfully. + /// Gets the FHIR DocumentReference ID when submitted successfully. /// public string? DocumentId { get; init; } @@ -110,9 +110,9 @@ public sealed record ErrorResponse } /// -/// Request body for the SubmitToEpic endpoint. +/// Request body for the SubmitToFhir endpoint. /// -public sealed record SubmitToEpicRequest +public sealed record SubmitToFhirRequest { /// /// Gets the FHIR Patient resource ID. @@ -125,7 +125,7 @@ public sealed record SubmitToEpicRequest public string? EncounterId { get; init; } /// - /// Gets the OAuth access token for Epic authentication. + /// Gets the OAuth access token for FHIR authentication. /// public required string AccessToken { get; init; } } diff --git a/apps/gateway/Gateway.API/Models/BundleEntry.cs b/apps/gateway/Gateway.API/Models/BundleEntry.cs deleted file mode 100644 index 8dd7e45..0000000 --- a/apps/gateway/Gateway.API/Models/BundleEntry.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// Entry within a FHIR Bundle containing a ServiceRequest resource. -/// -public sealed record BundleEntry -{ - /// - /// Gets the ServiceRequest resource for this entry. - /// - [JsonPropertyName("resource")] - public ServiceRequestResource? Resource { get; init; } -} diff --git a/apps/gateway/Gateway.API/Models/CdsAction.cs b/apps/gateway/Gateway.API/Models/CdsAction.cs deleted file mode 100644 index 1432c27..0000000 --- a/apps/gateway/Gateway.API/Models/CdsAction.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// A FHIR action to be performed when a CDS suggestion is accepted. -/// -public sealed record CdsAction -{ - /// - /// Gets the type of action: "create", "update", or "delete". - /// - [JsonPropertyName("type")] - public required string Type { get; init; } - - /// - /// Gets the human-readable description of this action. - /// - [JsonPropertyName("description")] - public string? Description { get; init; } - - /// - /// Gets the FHIR resource to create, update, or delete. - /// - [JsonPropertyName("resource")] - public object? Resource { get; init; } -} diff --git a/apps/gateway/Gateway.API/Models/CdsCard.cs b/apps/gateway/Gateway.API/Models/CdsCard.cs deleted file mode 100644 index df83995..0000000 --- a/apps/gateway/Gateway.API/Models/CdsCard.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// A CDS Hooks card representing a single piece of decision support to display. -/// -public sealed record CdsCard -{ - /// - /// Gets the unique identifier for this card. - /// - [JsonPropertyName("uuid")] - public string? Uuid { get; init; } - - /// - /// Gets the one-sentence summary of the card's recommendation. - /// - [JsonPropertyName("summary")] - public required string Summary { get; init; } - - /// - /// Gets the optional detailed information as markdown. - /// - [JsonPropertyName("detail")] - public string? Detail { get; init; } - - /// - /// Gets the urgency/severity indicator: "info", "warning", or "critical". - /// - [JsonPropertyName("indicator")] - public required string Indicator { get; init; } - - /// - /// Gets the source of the decision support content. - /// - [JsonPropertyName("source")] - public required CdsSource Source { get; init; } - - /// - /// Gets the suggested actions the user can take. - /// - [JsonPropertyName("suggestions")] - public List? Suggestions { get; init; } - - /// - /// Gets links to external resources or SMART apps. - /// - [JsonPropertyName("links")] - public List? Links { get; init; } - - /// - /// Gets the reasons a user can select when overriding this card. - /// - [JsonPropertyName("overrideReasons")] - public List? OverrideReasons { get; init; } -} diff --git a/apps/gateway/Gateway.API/Models/CdsContext.cs b/apps/gateway/Gateway.API/Models/CdsContext.cs deleted file mode 100644 index e303e48..0000000 --- a/apps/gateway/Gateway.API/Models/CdsContext.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// Context data for CDS Hooks requests including user and patient information. -/// -public sealed record CdsContext -{ - /// - /// Gets the FHIR ID of the current user (Practitioner resource). - /// - [JsonPropertyName("userId")] - public string? UserId { get; init; } - - /// - /// Gets the FHIR ID of the patient in context. - /// - [JsonPropertyName("patientId")] - public required string PatientId { get; init; } - - /// - /// Gets the FHIR ID of the current encounter, if any. - /// - [JsonPropertyName("encounterId")] - public string? EncounterId { get; init; } - - /// - /// Gets the draft orders being evaluated for decision support. - /// - [JsonPropertyName("draftOrders")] - public DraftOrders? DraftOrders { get; init; } -} diff --git a/apps/gateway/Gateway.API/Models/CdsLink.cs b/apps/gateway/Gateway.API/Models/CdsLink.cs deleted file mode 100644 index 669e3df..0000000 --- a/apps/gateway/Gateway.API/Models/CdsLink.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// A link to an external resource or SMART app from a CDS card. -/// -public sealed record CdsLink -{ - /// - /// Gets the human-readable label for this link. - /// - [JsonPropertyName("label")] - public required string Label { get; init; } - - /// - /// Gets the URL to navigate to when the link is clicked. - /// - [JsonPropertyName("url")] - public required string Url { get; init; } - - /// - /// Gets the link type: "absolute" for external URLs, "smart" for SMART app launches. - /// - [JsonPropertyName("type")] - public required string Type { get; init; } - - /// - /// Gets the SMART app launch context data for "smart" type links. - /// - [JsonPropertyName("appContext")] - public string? AppContext { get; init; } -} diff --git a/apps/gateway/Gateway.API/Models/CdsOverrideReason.cs b/apps/gateway/Gateway.API/Models/CdsOverrideReason.cs deleted file mode 100644 index 7785f0a..0000000 --- a/apps/gateway/Gateway.API/Models/CdsOverrideReason.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// A reason code that users can select when overriding a CDS card recommendation. -/// -public sealed record CdsOverrideReason -{ - /// - /// Gets the code identifier for this override reason. - /// - [JsonPropertyName("code")] - public required string Code { get; init; } - - /// - /// Gets the human-readable display text for this override reason. - /// - [JsonPropertyName("display")] - public required string Display { get; init; } -} diff --git a/apps/gateway/Gateway.API/Models/CdsPrefetch.cs b/apps/gateway/Gateway.API/Models/CdsPrefetch.cs deleted file mode 100644 index f7577e8..0000000 --- a/apps/gateway/Gateway.API/Models/CdsPrefetch.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// Prefetched FHIR resources provided by the CDS client to avoid additional queries. -/// -public sealed record CdsPrefetch -{ - /// - /// Gets the prefetched Patient resource. - /// - [JsonPropertyName("patient")] - public object? Patient { get; init; } - - /// - /// Gets the prefetched ServiceRequest resource. - /// - [JsonPropertyName("serviceRequest")] - public object? ServiceRequest { get; init; } -} diff --git a/apps/gateway/Gateway.API/Models/CdsRequest.cs b/apps/gateway/Gateway.API/Models/CdsRequest.cs deleted file mode 100644 index 400389d..0000000 --- a/apps/gateway/Gateway.API/Models/CdsRequest.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// CDS Hooks request payload for the order-select hook. -/// Contains context, authorization, and prefetch data for clinical decision support. -/// -public sealed record CdsRequest -{ - /// - /// Gets the unique identifier for this hook invocation. - /// - [JsonPropertyName("hookInstance")] - public required string HookInstance { get; init; } - - /// - /// Gets the name of the CDS hook being invoked (e.g., "order-select"). - /// - [JsonPropertyName("hook")] - public required string Hook { get; init; } - - /// - /// Gets the base URL of the FHIR server for additional queries. - /// - [JsonPropertyName("fhirServer")] - public string? FhirServer { get; init; } - - /// - /// Gets the OAuth 2.0 authorization for FHIR API access. - /// - [JsonPropertyName("fhirAuthorization")] - public FhirAuthorization? FhirAuthorization { get; init; } - - /// - /// Gets the context data including patient and draft orders. - /// - [JsonPropertyName("context")] - public required CdsContext Context { get; init; } - - /// - /// Gets prefetched FHIR resources to reduce network calls. - /// - [JsonPropertyName("prefetch")] - public CdsPrefetch? Prefetch { get; init; } -} diff --git a/apps/gateway/Gateway.API/Models/CdsResponse.cs b/apps/gateway/Gateway.API/Models/CdsResponse.cs deleted file mode 100644 index eb6d826..0000000 --- a/apps/gateway/Gateway.API/Models/CdsResponse.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// CDS Hooks response containing decision support cards to display in the EHR. -/// -public sealed record CdsResponse -{ - /// - /// Gets the collection of cards to display for clinical decision support. - /// - [JsonPropertyName("cards")] - public required List Cards { get; init; } -} diff --git a/apps/gateway/Gateway.API/Models/CdsSource.cs b/apps/gateway/Gateway.API/Models/CdsSource.cs deleted file mode 100644 index c05f8f3..0000000 --- a/apps/gateway/Gateway.API/Models/CdsSource.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// Source information for a CDS Hooks card identifying the decision support provider. -/// -public sealed record CdsSource -{ - /// - /// Gets the short display label for the source (e.g., "AuthScript PA System"). - /// - [JsonPropertyName("label")] - public required string Label { get; init; } - - /// - /// Gets the optional URL to the source's website. - /// - [JsonPropertyName("url")] - public string? Url { get; init; } - - /// - /// Gets the optional URL to an icon image for the source. - /// - [JsonPropertyName("icon")] - public string? Icon { get; init; } -} diff --git a/apps/gateway/Gateway.API/Models/CdsSuggestion.cs b/apps/gateway/Gateway.API/Models/CdsSuggestion.cs deleted file mode 100644 index b065d75..0000000 --- a/apps/gateway/Gateway.API/Models/CdsSuggestion.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// A suggested action group that the user can accept from a CDS card. -/// -public sealed record CdsSuggestion -{ - /// - /// Gets the human-readable label for this suggestion. - /// - [JsonPropertyName("label")] - public required string Label { get; init; } - - /// - /// Gets the unique identifier for this suggestion. - /// - [JsonPropertyName("uuid")] - public string? Uuid { get; init; } - - /// - /// Gets whether this suggestion is the recommended choice. - /// - [JsonPropertyName("isRecommended")] - public bool? IsRecommended { get; init; } - - /// - /// Gets the list of FHIR actions to execute when this suggestion is accepted. - /// - [JsonPropertyName("actions")] - public List? Actions { get; init; } -} diff --git a/apps/gateway/Gateway.API/Models/DraftOrders.cs b/apps/gateway/Gateway.API/Models/DraftOrders.cs deleted file mode 100644 index ab79850..0000000 --- a/apps/gateway/Gateway.API/Models/DraftOrders.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// FHIR Bundle containing draft ServiceRequest resources for order-select hook. -/// -public sealed record DraftOrders -{ - /// - /// Gets the FHIR resource type, always "Bundle". - /// - [JsonPropertyName("resourceType")] - public string ResourceType { get; init; } = "Bundle"; - - /// - /// Gets the bundle entries containing draft orders. - /// - [JsonPropertyName("entry")] - public List? Entry { get; init; } -} diff --git a/apps/gateway/Gateway.API/Models/ServiceRequestResource.cs b/apps/gateway/Gateway.API/Models/ServiceRequestResource.cs deleted file mode 100644 index 68a5ba1..0000000 --- a/apps/gateway/Gateway.API/Models/ServiceRequestResource.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Gateway.API.Models; - -/// -/// FHIR ServiceRequest resource representing an order for a service or procedure. -/// -public sealed record ServiceRequestResource -{ - /// - /// Gets the FHIR resource type, always "ServiceRequest". - /// - [JsonPropertyName("resourceType")] - public string ResourceType { get; init; } = "ServiceRequest"; - - /// - /// Gets the logical ID of this resource. - /// - [JsonPropertyName("id")] - public string? Id { get; init; } - - /// - /// Gets the code describing what is being requested (procedure/service). - /// - [JsonPropertyName("code")] - public CodeableConcept? Code { get; init; } -} diff --git a/apps/gateway/Gateway.API/Program.cs b/apps/gateway/Gateway.API/Program.cs index f5ee045..6f9d291 100644 --- a/apps/gateway/Gateway.API/Program.cs +++ b/apps/gateway/Gateway.API/Program.cs @@ -1,11 +1,10 @@ // =========================================================================== // AuthScript Gateway Service -// Handles CDS Hooks, FHIR data aggregation, and PDF generation +// Handles FHIR data aggregation, PA analysis, and PDF generation // =========================================================================== -using Gateway.API.Contracts; +using Gateway.API; using Gateway.API.Endpoints; -using Gateway.API.Services; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -24,26 +23,10 @@ // PostgreSQL builder.AddNpgsqlDataSource("authscript"); -// HTTP clients with resilience -builder.Services.AddHttpClient(client => -{ - var baseUrl = builder.Configuration["Intelligence:BaseUrl"] ?? "http://localhost:8000"; - client.BaseAddress = new Uri(baseUrl); - client.Timeout = TimeSpan.FromSeconds(30); -}); - -builder.Services.AddHttpClient(client => -{ - var baseUrl = builder.Configuration["Epic:FhirBaseUrl"] - ?? "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4"; - client.BaseAddress = new Uri(baseUrl); -}); - -// Application services -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); +// Gateway services +builder.Services.AddGatewayServices(builder.Configuration); +builder.Services.AddFhirClients(builder.Configuration); +builder.Services.AddIntelligenceClient(builder.Configuration); // CORS for dashboard builder.Services.AddCors(options => @@ -79,7 +62,6 @@ // --------------------------------------------------------------------------- // Endpoint Mapping // --------------------------------------------------------------------------- -app.MapCdsHooksEndpoints(); app.MapAnalysisEndpoints(); app.Run(); diff --git a/apps/gateway/Gateway.API/Services/DemoCacheService.cs b/apps/gateway/Gateway.API/Services/AnalysisResultStore.cs similarity index 65% rename from apps/gateway/Gateway.API/Services/DemoCacheService.cs rename to apps/gateway/Gateway.API/Services/AnalysisResultStore.cs index 640d389..e6400e5 100644 --- a/apps/gateway/Gateway.API/Services/DemoCacheService.cs +++ b/apps/gateway/Gateway.API/Services/AnalysisResultStore.cs @@ -6,26 +6,26 @@ namespace Gateway.API.Services; /// -/// Redis-based caching service for demo mode. -/// Gracefully handles missing Redis connections by disabling caching. +/// Redis-based storage for completed analysis results. +/// Gracefully handles missing Redis connections by disabling storage. /// -public sealed class DemoCacheService : IDemoCacheService +public sealed class AnalysisResultStore : IAnalysisResultStore { private readonly IConnectionMultiplexer? _redis; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IConfiguration _configuration; - private const string KeyPrefix = "authscript:demo"; + private const string KeyPrefix = "authscript:analysis"; private static readonly TimeSpan DefaultTtl = TimeSpan.FromHours(24); /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Logger for diagnostic output. - /// Configuration for cache settings. + /// Configuration for storage settings. /// Optional Redis connection multiplexer. - public DemoCacheService( - ILogger logger, + public AnalysisResultStore( + ILogger logger, IConfiguration configuration, IConnectionMultiplexer? redis = null) { @@ -37,7 +37,7 @@ public DemoCacheService( /// public async Task GetCachedResponseAsync(string cacheKey, CancellationToken cancellationToken = default) { - if (!IsCachingEnabled() || _redis is null) + if (!IsStorageEnabled() || _redis is null) return null; try @@ -48,16 +48,16 @@ public DemoCacheService( if (value.IsNullOrEmpty) { - _logger.LogDebug("Cache miss for {Key}", key); + _logger.LogDebug("Store miss for {Key}", key); return null; } - _logger.LogDebug("Cache hit for {Key}", key); + _logger.LogDebug("Store hit for {Key}", key); return JsonSerializer.Deserialize((string)value!); } catch (Exception ex) { - _logger.LogWarning(ex, "Cache read failed for {CacheKey}", cacheKey); + _logger.LogWarning(ex, "Store read failed for {CacheKey}", cacheKey); return null; } } @@ -65,7 +65,7 @@ public DemoCacheService( /// public async Task SetCachedResponseAsync(string cacheKey, PAFormData formData, CancellationToken cancellationToken = default) { - if (!IsCachingEnabled() || _redis is null) + if (!IsStorageEnabled() || _redis is null) return; try @@ -75,18 +75,18 @@ public async Task SetCachedResponseAsync(string cacheKey, PAFormData formData, C var json = JsonSerializer.Serialize(formData); await db.StringSetAsync(key, json, DefaultTtl); - _logger.LogDebug("Cached response for {Key}", key); + _logger.LogDebug("Stored response for {Key}", key); } catch (Exception ex) { - _logger.LogWarning(ex, "Cache write failed for {CacheKey}", cacheKey); + _logger.LogWarning(ex, "Store write failed for {CacheKey}", cacheKey); } } /// public async Task GetCachedPdfAsync(string cacheKey, CancellationToken cancellationToken = default) { - if (!IsCachingEnabled() || _redis is null) + if (!IsStorageEnabled() || _redis is null) return null; try @@ -97,16 +97,16 @@ public async Task SetCachedResponseAsync(string cacheKey, PAFormData formData, C if (value.IsNullOrEmpty) { - _logger.LogDebug("PDF cache miss for {Key}", key); + _logger.LogDebug("PDF store miss for {Key}", key); return null; } - _logger.LogDebug("PDF cache hit for {Key}", key); + _logger.LogDebug("PDF store hit for {Key}", key); return (byte[]?)value; } catch (Exception ex) { - _logger.LogWarning(ex, "PDF cache read failed for {CacheKey}", cacheKey); + _logger.LogWarning(ex, "PDF store read failed for {CacheKey}", cacheKey); return null; } } @@ -114,7 +114,7 @@ public async Task SetCachedResponseAsync(string cacheKey, PAFormData formData, C /// public async Task SetCachedPdfAsync(string cacheKey, byte[] pdfBytes, CancellationToken cancellationToken = default) { - if (!IsCachingEnabled() || _redis is null) + if (!IsStorageEnabled() || _redis is null) return; try @@ -123,16 +123,16 @@ public async Task SetCachedPdfAsync(string cacheKey, byte[] pdfBytes, Cancellati var key = $"{KeyPrefix}:pdf:{cacheKey}"; await db.StringSetAsync(key, pdfBytes, DefaultTtl); - _logger.LogDebug("Cached PDF for {Key}", key); + _logger.LogDebug("Stored PDF for {Key}", key); } catch (Exception ex) { - _logger.LogWarning(ex, "PDF cache write failed for {CacheKey}", cacheKey); + _logger.LogWarning(ex, "PDF store write failed for {CacheKey}", cacheKey); } } - private bool IsCachingEnabled() + private bool IsStorageEnabled() { - return _configuration.GetValue("Demo:EnableCaching", true); + return _configuration.GetValue("Analysis:EnableResultStorage", true); } } diff --git a/apps/gateway/Gateway.API/Services/Decorators/CachingIntelligenceClient.cs b/apps/gateway/Gateway.API/Services/Decorators/CachingIntelligenceClient.cs new file mode 100644 index 0000000..1fdb9e3 --- /dev/null +++ b/apps/gateway/Gateway.API/Services/Decorators/CachingIntelligenceClient.cs @@ -0,0 +1,69 @@ +using Gateway.API.Configuration; +using Gateway.API.Contracts; +using Gateway.API.Models; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Options; + +namespace Gateway.API.Services.Decorators; + +/// +/// Decorator that adds HybridCache caching to the Intelligence client. +/// Uses a two-tier cache (L1 in-memory + L2 Redis) for optimal performance. +/// +public sealed class CachingIntelligenceClient : IIntelligenceClient +{ + private readonly IIntelligenceClient _inner; + private readonly HybridCache _cache; + private readonly CachingSettings _settings; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The inner intelligence client to decorate. + /// The hybrid cache instance. + /// Caching configuration settings. + /// Logger for diagnostic output. + public CachingIntelligenceClient( + IIntelligenceClient inner, + HybridCache cache, + IOptions settings, + ILogger logger) + { + _inner = inner; + _cache = cache; + _settings = settings.Value; + _logger = logger; + } + + /// + public async Task AnalyzeAsync( + ClinicalBundle clinicalBundle, + string procedureCode, + CancellationToken cancellationToken = default) + { + var cacheKey = BuildCacheKey(clinicalBundle.PatientId, procedureCode); + + var result = await _cache.GetOrCreateAsync( + cacheKey, + async ct => + { + _logger.LogDebug("Cache miss for {CacheKey}, calling intelligence service", cacheKey); + return await _inner.AnalyzeAsync(clinicalBundle, procedureCode, ct); + }, + new HybridCacheEntryOptions + { + Expiration = _settings.Duration, + LocalCacheExpiration = _settings.LocalCacheDuration + }, + cancellationToken: cancellationToken); + + _logger.LogDebug("Analysis result retrieved for {CacheKey}", cacheKey); + return result; + } + + private string BuildCacheKey(string patientId, string procedureCode) + { + return $"{_settings.KeyPrefix}:analysis:{patientId}:{procedureCode}"; + } +} diff --git a/apps/gateway/Gateway.API/Services/DocumentUploader.cs b/apps/gateway/Gateway.API/Services/DocumentUploader.cs new file mode 100644 index 0000000..c827c46 --- /dev/null +++ b/apps/gateway/Gateway.API/Services/DocumentUploader.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using Gateway.API.Configuration; +using Gateway.API.Contracts; +using Microsoft.Extensions.Options; + +namespace Gateway.API.Services; + +/// +/// Uploads documents to a FHIR server as DocumentReference resources. +/// Uses IFhirHttpClient for HTTP operations. +/// +public sealed class DocumentUploader : IDocumentUploader +{ + private readonly IFhirHttpClient _fhirHttpClient; + private readonly ILogger _logger; + private readonly DocumentOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// Low-level FHIR HTTP client. + /// Logger for diagnostic output. + /// Document configuration options. + public DocumentUploader( + IFhirHttpClient fhirHttpClient, + ILogger logger, + IOptions options) + { + _fhirHttpClient = fhirHttpClient; + _logger = logger; + _options = options.Value; + } + + /// + public async Task> UploadDocumentAsync( + byte[] pdfBytes, + string patientId, + string? encounterId, + string accessToken, + CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "Uploading PA form. Size={Size} bytes", + pdfBytes.Length); + + var documentReference = BuildDocumentReference(pdfBytes, patientId, encounterId); + var json = JsonSerializer.Serialize(documentReference); + + var result = await _fhirHttpClient.CreateAsync("DocumentReference", json, accessToken, cancellationToken); + + if (result.IsFailure) + { + _logger.LogError( + "Failed to upload document: {Error}", + result.Error?.Message); + return Result.Failure(result.Error!); + } + + var responseJson = result.Value!; + string documentId; + if (!responseJson.TryGetProperty("id", out var id) || string.IsNullOrEmpty(id.GetString())) + { + _logger.LogWarning("FHIR server response missing document ID, generating synthetic ID"); + documentId = Guid.NewGuid().ToString(); + } + else + { + documentId = id.GetString()!; + } + + _logger.LogInformation("Document uploaded successfully. DocumentId={DocumentId}", documentId); + + return Result.Success(documentId); + } + + private object BuildDocumentReference(byte[] pdfBytes, string patientId, string? encounterId) + { + return new + { + resourceType = "DocumentReference", + status = "current", + type = new + { + coding = new[] + { + new + { + system = "http://loinc.org", + code = _options.PriorAuthLoincCode, + display = _options.PriorAuthLoincDisplay + } + } + }, + subject = new + { + reference = $"Patient/{patientId}" + }, + context = encounterId is not null + ? new + { + encounter = new[] + { + new { reference = $"Encounter/{encounterId}" } + } + } + : null, + content = new[] + { + new + { + attachment = new + { + contentType = "application/pdf", + data = Convert.ToBase64String(pdfBytes), + title = $"PA Form - {DateTime.UtcNow:yyyy-MM-dd}" + } + } + } + }; + } +} diff --git a/apps/gateway/Gateway.API/Services/EpicFhirClient.cs b/apps/gateway/Gateway.API/Services/EpicFhirClient.cs deleted file mode 100644 index 83a847c..0000000 --- a/apps/gateway/Gateway.API/Services/EpicFhirClient.cs +++ /dev/null @@ -1,382 +0,0 @@ -using System.Net.Http.Headers; -using System.Text.Json; -using Gateway.API.Contracts; -using Gateway.API.Models; - -namespace Gateway.API.Services; - -/// -/// HTTP client implementation for Epic's FHIR R4 API. -/// Handles authentication, request formatting, and response parsing. -/// -public sealed class EpicFhirClient : IEpicFhirClient -{ - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// HTTP client configured with Epic's FHIR base URL. - /// Logger for diagnostic output. - public EpicFhirClient(HttpClient httpClient, ILogger logger) - { - _httpClient = httpClient; - _logger = logger; - } - - /// - public async Task GetPatientAsync( - string patientId, - string accessToken, - CancellationToken cancellationToken = default) - { - using var request = new HttpRequestMessage(HttpMethod.Get, $"Patient/{patientId}"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json")); - - var response = await _httpClient.SendAsync(request, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to fetch patient {PatientId}: {Status}", patientId, response.StatusCode); - return null; - } - - var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - - return new PatientInfo - { - Id = patientId, - GivenName = ExtractName(json, "given"), - FamilyName = ExtractName(json, "family"), - BirthDate = ExtractDate(json, "birthDate"), - Gender = json.TryGetProperty("gender", out var gender) ? gender.GetString() : null - }; - } - - /// - public async Task> SearchConditionsAsync( - string patientId, - string accessToken, - CancellationToken cancellationToken = default) - { - var results = new List(); - - using var request = new HttpRequestMessage( - HttpMethod.Get, - $"Condition?patient={patientId}&clinical-status=active"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json")); - - var response = await _httpClient.SendAsync(request, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to search conditions for {PatientId}: {Status}", patientId, response.StatusCode); - return results; - } - - var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - - if (json.TryGetProperty("entry", out var entries)) - { - foreach (var entry in entries.EnumerateArray()) - { - if (entry.TryGetProperty("resource", out var resource)) - { - var coding = ExtractFirstCoding(resource, "code"); - if (coding is not null) - { - results.Add(new ConditionInfo - { - Id = resource.TryGetProperty("id", out var id) ? id.GetString()! : Guid.NewGuid().ToString(), - Code = coding.Value.code, - CodeSystem = coding.Value.system, - Display = coding.Value.display, - ClinicalStatus = ExtractClinicalStatus(resource) - }); - } - } - } - } - - return results; - } - - /// - public async Task> SearchObservationsAsync( - string patientId, - DateOnly since, - string accessToken, - CancellationToken cancellationToken = default) - { - var results = new List(); - - using var request = new HttpRequestMessage( - HttpMethod.Get, - $"Observation?patient={patientId}&category=laboratory&date=ge{since:yyyy-MM-dd}"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json")); - - var response = await _httpClient.SendAsync(request, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to search observations for {PatientId}: {Status}", patientId, response.StatusCode); - return results; - } - - var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - - if (json.TryGetProperty("entry", out var entries)) - { - foreach (var entry in entries.EnumerateArray()) - { - if (entry.TryGetProperty("resource", out var resource)) - { - var coding = ExtractFirstCoding(resource, "code"); - if (coding is not null) - { - results.Add(new ObservationInfo - { - Id = resource.TryGetProperty("id", out var id) ? id.GetString()! : Guid.NewGuid().ToString(), - Code = coding.Value.code, - CodeSystem = coding.Value.system, - Display = coding.Value.display, - Value = ExtractObservationValue(resource), - Unit = ExtractObservationUnit(resource) - }); - } - } - } - } - - return results; - } - - /// - public async Task> SearchProceduresAsync( - string patientId, - DateOnly since, - string accessToken, - CancellationToken cancellationToken = default) - { - var results = new List(); - - using var request = new HttpRequestMessage( - HttpMethod.Get, - $"Procedure?patient={patientId}&date=ge{since:yyyy-MM-dd}"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json")); - - var response = await _httpClient.SendAsync(request, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to search procedures for {PatientId}: {Status}", patientId, response.StatusCode); - return results; - } - - var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - - if (json.TryGetProperty("entry", out var entries)) - { - foreach (var entry in entries.EnumerateArray()) - { - if (entry.TryGetProperty("resource", out var resource)) - { - var coding = ExtractFirstCoding(resource, "code"); - if (coding is not null) - { - results.Add(new ProcedureInfo - { - Id = resource.TryGetProperty("id", out var id) ? id.GetString()! : Guid.NewGuid().ToString(), - Code = coding.Value.code, - CodeSystem = coding.Value.system, - Display = coding.Value.display, - Status = resource.TryGetProperty("status", out var status) ? status.GetString() : null - }); - } - } - } - } - - return results; - } - - /// - public async Task> SearchDocumentsAsync( - string patientId, - string accessToken, - CancellationToken cancellationToken = default) - { - var results = new List(); - - using var request = new HttpRequestMessage( - HttpMethod.Get, - $"DocumentReference?patient={patientId}&status=current"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json")); - - var response = await _httpClient.SendAsync(request, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to search documents for {PatientId}: {Status}", patientId, response.StatusCode); - return results; - } - - var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - - if (json.TryGetProperty("entry", out var entries)) - { - foreach (var entry in entries.EnumerateArray()) - { - if (entry.TryGetProperty("resource", out var resource)) - { - var docId = resource.TryGetProperty("id", out var id) ? id.GetString()! : Guid.NewGuid().ToString(); - var type = ExtractFirstCoding(resource, "type"); - - results.Add(new DocumentInfo - { - Id = docId, - Type = type?.display ?? type?.code ?? "Unknown", - ContentType = ExtractContentType(resource), - Title = ExtractDocumentTitle(resource) - }); - } - } - } - - return results; - } - - /// - public async Task GetDocumentContentAsync( - string documentId, - string accessToken, - CancellationToken cancellationToken = default) - { - using var request = new HttpRequestMessage(HttpMethod.Get, $"Binary/{documentId}"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - - var response = await _httpClient.SendAsync(request, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to fetch document content {DocumentId}: {Status}", documentId, response.StatusCode); - return null; - } - - return await response.Content.ReadAsByteArrayAsync(cancellationToken); - } - - private static string? ExtractName(JsonElement json, string part) - { - if (!json.TryGetProperty("name", out var names)) return null; - - foreach (var name in names.EnumerateArray()) - { - if (part == "given" && name.TryGetProperty("given", out var given)) - { - var givenNames = new List(); - foreach (var g in given.EnumerateArray()) - { - givenNames.Add(g.GetString() ?? ""); - } - return string.Join(" ", givenNames); - } - if (part == "family" && name.TryGetProperty("family", out var family)) - { - return family.GetString(); - } - } - - return null; - } - - private static DateOnly? ExtractDate(JsonElement json, string property) - { - if (!json.TryGetProperty(property, out var value)) return null; - if (DateOnly.TryParse(value.GetString(), out var date)) return date; - return null; - } - - private static (string code, string? system, string? display)? ExtractFirstCoding(JsonElement json, string property) - { - if (!json.TryGetProperty(property, out var codeableConcept)) return null; - if (!codeableConcept.TryGetProperty("coding", out var codings)) return null; - - foreach (var coding in codings.EnumerateArray()) - { - var code = coding.TryGetProperty("code", out var c) ? c.GetString() : null; - if (code is null) continue; - - var system = coding.TryGetProperty("system", out var s) ? s.GetString() : null; - var display = coding.TryGetProperty("display", out var d) ? d.GetString() : null; - - return (code, system, display); - } - - return null; - } - - private static string? ExtractClinicalStatus(JsonElement resource) - { - if (!resource.TryGetProperty("clinicalStatus", out var status)) return null; - var coding = ExtractFirstCoding(status, "coding"); - return coding?.code; - } - - private static string? ExtractObservationValue(JsonElement resource) - { - if (resource.TryGetProperty("valueQuantity", out var quantity)) - { - return quantity.TryGetProperty("value", out var v) ? v.ToString() : null; - } - if (resource.TryGetProperty("valueString", out var str)) - { - return str.GetString(); - } - return null; - } - - private static string? ExtractObservationUnit(JsonElement resource) - { - if (!resource.TryGetProperty("valueQuantity", out var quantity)) return null; - return quantity.TryGetProperty("unit", out var unit) ? unit.GetString() : null; - } - - private static string? ExtractContentType(JsonElement resource) - { - if (!resource.TryGetProperty("content", out var contents)) return null; - foreach (var content in contents.EnumerateArray()) - { - if (content.TryGetProperty("attachment", out var attachment)) - { - if (attachment.TryGetProperty("contentType", out var ct)) - { - return ct.GetString(); - } - } - } - return null; - } - - private static string? ExtractDocumentTitle(JsonElement resource) - { - if (!resource.TryGetProperty("content", out var contents)) return null; - foreach (var content in contents.EnumerateArray()) - { - if (content.TryGetProperty("attachment", out var attachment)) - { - if (attachment.TryGetProperty("title", out var title)) - { - return title.GetString(); - } - } - } - return null; - } -} diff --git a/apps/gateway/Gateway.API/Services/EpicUploader.cs b/apps/gateway/Gateway.API/Services/EpicUploader.cs deleted file mode 100644 index 8df4400..0000000 --- a/apps/gateway/Gateway.API/Services/EpicUploader.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using Gateway.API.Contracts; - -namespace Gateway.API.Services; - -/// -/// HTTP client implementation for uploading documents to Epic's FHIR server. -/// Creates FHIR DocumentReference resources with embedded PDF content. -/// -public sealed class EpicUploader : IEpicUploader -{ - private readonly IEpicFhirClient _fhirClient; - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - private readonly IConfiguration _configuration; - - /// - /// Initializes a new instance of the class. - /// - /// The Epic FHIR client for reference. - /// HTTP client for direct FHIR calls. - /// Logger for diagnostic output. - /// Configuration for Epic FHIR base URL. - public EpicUploader( - IEpicFhirClient fhirClient, - HttpClient httpClient, - ILogger logger, - IConfiguration configuration) - { - _fhirClient = fhirClient; - _httpClient = httpClient; - _logger = logger; - _configuration = configuration; - } - - /// - public async Task UploadDocumentAsync( - byte[] pdfBytes, - string patientId, - string? encounterId, - string accessToken, - CancellationToken cancellationToken = default) - { - _logger.LogInformation( - "Uploading PA form to Epic. PatientId={PatientId}, Size={Size} bytes", - patientId, pdfBytes.Length); - - var documentReference = new - { - resourceType = "DocumentReference", - status = "current", - type = new - { - coding = new[] - { - new - { - system = "http://loinc.org", - code = "64289-6", - display = "Prior authorization request" - } - } - }, - subject = new - { - reference = $"Patient/{patientId}" - }, - context = encounterId is not null - ? new - { - encounter = new[] - { - new { reference = $"Encounter/{encounterId}" } - } - } - : null, - content = new[] - { - new - { - attachment = new - { - contentType = "application/pdf", - data = Convert.ToBase64String(pdfBytes), - title = $"AuthScript PA Form - {DateTime.UtcNow:yyyy-MM-dd}" - } - } - } - }; - - var json = JsonSerializer.Serialize(documentReference); - var content = new StringContent(json, Encoding.UTF8, "application/fhir+json"); - - var baseUrl = _configuration["Epic:FhirBaseUrl"] - ?? "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4"; - - using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/DocumentReference"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - request.Content = content; - - var response = await _httpClient.SendAsync(request, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - var error = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogError("Failed to upload document: {Status} - {Error}", response.StatusCode, error); - throw new HttpRequestException($"Epic returned {response.StatusCode}: {error}"); - } - - var responseJson = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - - var documentId = responseJson.TryGetProperty("id", out var id) - ? id.GetString() - : Guid.NewGuid().ToString(); - - _logger.LogInformation("Document uploaded successfully. DocumentId={DocumentId}", documentId); - - return documentId!; - } -} diff --git a/apps/gateway/Gateway.API/Services/Fhir/EpicFhirContext.cs b/apps/gateway/Gateway.API/Services/Fhir/EpicFhirContext.cs index 6e43935..fe606a4 100644 --- a/apps/gateway/Gateway.API/Services/Fhir/EpicFhirContext.cs +++ b/apps/gateway/Gateway.API/Services/Fhir/EpicFhirContext.cs @@ -81,6 +81,12 @@ public async Task>> SearchAsync( var response = await _httpClient.SendAsync(request, ct); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return Result>.Failure( + FhirError.InvalidResponse($"FHIR {_resourceType} search endpoint not found")); + } + if (response.StatusCode == HttpStatusCode.Unauthorized) { return Result>.Failure(FhirError.Unauthorized()); diff --git a/apps/gateway/Gateway.API/Services/Fhir/FhirHttpClient.cs b/apps/gateway/Gateway.API/Services/Fhir/FhirHttpClient.cs new file mode 100644 index 0000000..db07736 --- /dev/null +++ b/apps/gateway/Gateway.API/Services/Fhir/FhirHttpClient.cs @@ -0,0 +1,195 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Gateway.API.Contracts; + +namespace Gateway.API.Services.Fhir; + +/// +/// HTTP client implementation for FHIR R4 API operations. +/// Handles authentication, request formatting, and response handling. +/// +public sealed class FhirHttpClient : IFhirHttpClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// HTTP client configured with FHIR base URL. + /// Logger for diagnostic output. + public FhirHttpClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + /// + public async Task> ReadAsync( + string resourceType, + string id, + string accessToken, + CancellationToken ct = default) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, $"{resourceType}/{id}"); + ConfigureRequest(request, accessToken); + + var response = await _httpClient.SendAsync(request, ct); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return Result.Failure(FhirError.NotFound(resourceType, id)); + } + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + return Result.Failure(FhirError.Unauthorized()); + } + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + return Result.Success(json); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Network error reading {ResourceType}/{Id}", resourceType, id); + return Result.Failure(FhirError.Network(ex.Message, ex)); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Invalid JSON response reading {ResourceType}/{Id}", resourceType, id); + return Result.Failure(FhirError.Validation($"Invalid JSON response: {ex.Message}")); + } + } + + /// + public async Task> SearchAsync( + string resourceType, + string query, + string accessToken, + CancellationToken ct = default) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, $"{resourceType}?{query}"); + ConfigureRequest(request, accessToken); + + var response = await _httpClient.SendAsync(request, ct); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return Result.Failure( + FhirError.InvalidResponse($"FHIR {resourceType} search endpoint not found")); + } + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + return Result.Failure(FhirError.Unauthorized()); + } + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + return Result.Success(json); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Network error searching {ResourceType}", resourceType); + return Result.Failure(FhirError.Network(ex.Message, ex)); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Invalid JSON response searching {ResourceType}", resourceType); + return Result.Failure(FhirError.Validation($"Invalid JSON response: {ex.Message}")); + } + } + + /// + public async Task> CreateAsync( + string resourceType, + string resourceJson, + string accessToken, + CancellationToken ct = default) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, resourceType); + ConfigureRequest(request, accessToken); + request.Content = new StringContent(resourceJson, Encoding.UTF8, "application/fhir+json"); + + var response = await _httpClient.SendAsync(request, ct); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + return Result.Failure(FhirError.Unauthorized()); + } + + if (response.StatusCode == HttpStatusCode.UnprocessableEntity) + { + var error = await response.Content.ReadAsStringAsync(ct); + return Result.Failure(FhirError.Validation(error)); + } + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + return Result.Success(json); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Network error creating {ResourceType}", resourceType); + return Result.Failure(FhirError.Network(ex.Message, ex)); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Invalid JSON response creating {ResourceType}", resourceType); + return Result.Failure(FhirError.Validation($"Invalid JSON response: {ex.Message}")); + } + } + + /// + public async Task> ReadBinaryAsync( + string id, + string accessToken, + CancellationToken ct = default) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, $"Binary/{id}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await _httpClient.SendAsync(request, ct); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return Result.Failure(FhirError.NotFound("Binary", id)); + } + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + return Result.Failure(FhirError.Unauthorized()); + } + + response.EnsureSuccessStatusCode(); + + var bytes = await response.Content.ReadAsByteArrayAsync(ct); + return Result.Success(bytes); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Network error reading Binary/{Id}", id); + return Result.Failure(FhirError.Network(ex.Message, ex)); + } + } + + private static void ConfigureRequest(HttpRequestMessage request, string accessToken) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json")); + } +} diff --git a/apps/gateway/Gateway.API/Services/FhirClient.cs b/apps/gateway/Gateway.API/Services/FhirClient.cs new file mode 100644 index 0000000..a8f3136 --- /dev/null +++ b/apps/gateway/Gateway.API/Services/FhirClient.cs @@ -0,0 +1,359 @@ +using System.Text.Json; +using Gateway.API.Contracts; +using Gateway.API.Models; + +namespace Gateway.API.Services; + +/// +/// High-level FHIR client implementation. +/// Delegates HTTP operations to IFhirHttpClient and maps responses to domain DTOs. +/// +public sealed class FhirClient : IFhirClient +{ + private readonly IFhirHttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Low-level FHIR HTTP client. + /// Logger for diagnostic output. + public FhirClient(IFhirHttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + /// + public async Task GetPatientAsync( + string patientId, + string accessToken, + CancellationToken cancellationToken = default) + { + var result = await _httpClient.ReadAsync("Patient", patientId, accessToken, cancellationToken); + + if (result.IsFailure) + { + _logger.LogWarning( + "Failed to fetch patient {PatientId}: {Error}", + patientId, + result.Error?.Message); + return null; + } + + var json = result.Value!; + return new PatientInfo + { + Id = patientId, + GivenName = ExtractName(json, "given"), + FamilyName = ExtractName(json, "family"), + BirthDate = ExtractDate(json, "birthDate"), + Gender = json.TryGetProperty("gender", out var gender) ? gender.GetString() : null + }; + } + + /// + public async Task> SearchConditionsAsync( + string patientId, + string accessToken, + CancellationToken cancellationToken = default) + { + var results = new List(); + var result = await _httpClient.SearchAsync( + "Condition", + $"patient={patientId}&clinical-status=active", + accessToken, + cancellationToken); + + if (result.IsFailure) + { + _logger.LogWarning( + "Failed to search conditions for {PatientId}: {Error}", + patientId, + result.Error?.Message); + return results; + } + + var json = result.Value!; + if (!json.TryGetProperty("entry", out var entries)) return results; + + foreach (var entry in entries.EnumerateArray()) + { + if (!entry.TryGetProperty("resource", out var resource)) continue; + + var coding = ExtractFirstCoding(resource, "code"); + if (coding is not null) + { + results.Add(new ConditionInfo + { + Id = resource.TryGetProperty("id", out var id) ? id.GetString()! : Guid.NewGuid().ToString(), + Code = coding.Value.code, + CodeSystem = coding.Value.system, + Display = coding.Value.display, + ClinicalStatus = ExtractClinicalStatus(resource) + }); + } + } + + return results; + } + + /// + public async Task> SearchObservationsAsync( + string patientId, + DateOnly since, + string accessToken, + CancellationToken cancellationToken = default) + { + var results = new List(); + var result = await _httpClient.SearchAsync( + "Observation", + $"patient={patientId}&category=laboratory&date=ge{since:yyyy-MM-dd}", + accessToken, + cancellationToken); + + if (result.IsFailure) + { + _logger.LogWarning( + "Failed to search observations for {PatientId}: {Error}", + patientId, + result.Error?.Message); + return results; + } + + var json = result.Value!; + if (!json.TryGetProperty("entry", out var entries)) return results; + + foreach (var entry in entries.EnumerateArray()) + { + if (!entry.TryGetProperty("resource", out var resource)) continue; + + var coding = ExtractFirstCoding(resource, "code"); + if (coding is not null) + { + results.Add(new ObservationInfo + { + Id = resource.TryGetProperty("id", out var id) ? id.GetString()! : Guid.NewGuid().ToString(), + Code = coding.Value.code, + CodeSystem = coding.Value.system, + Display = coding.Value.display, + Value = ExtractObservationValue(resource), + Unit = ExtractObservationUnit(resource) + }); + } + } + + return results; + } + + /// + public async Task> SearchProceduresAsync( + string patientId, + DateOnly since, + string accessToken, + CancellationToken cancellationToken = default) + { + var results = new List(); + var result = await _httpClient.SearchAsync( + "Procedure", + $"patient={patientId}&date=ge{since:yyyy-MM-dd}", + accessToken, + cancellationToken); + + if (result.IsFailure) + { + _logger.LogWarning( + "Failed to search procedures for {PatientId}: {Error}", + patientId, + result.Error?.Message); + return results; + } + + var json = result.Value!; + if (!json.TryGetProperty("entry", out var entries)) return results; + + foreach (var entry in entries.EnumerateArray()) + { + if (!entry.TryGetProperty("resource", out var resource)) continue; + + var coding = ExtractFirstCoding(resource, "code"); + if (coding is not null) + { + results.Add(new ProcedureInfo + { + Id = resource.TryGetProperty("id", out var id) ? id.GetString()! : Guid.NewGuid().ToString(), + Code = coding.Value.code, + CodeSystem = coding.Value.system, + Display = coding.Value.display, + Status = resource.TryGetProperty("status", out var status) ? status.GetString() : null + }); + } + } + + return results; + } + + /// + public async Task> SearchDocumentsAsync( + string patientId, + string accessToken, + CancellationToken cancellationToken = default) + { + var results = new List(); + var result = await _httpClient.SearchAsync( + "DocumentReference", + $"patient={patientId}&status=current", + accessToken, + cancellationToken); + + if (result.IsFailure) + { + _logger.LogWarning( + "Failed to search documents for {PatientId}: {Error}", + patientId, + result.Error?.Message); + return results; + } + + var json = result.Value!; + if (!json.TryGetProperty("entry", out var entries)) return results; + + foreach (var entry in entries.EnumerateArray()) + { + if (!entry.TryGetProperty("resource", out var resource)) continue; + + var docId = resource.TryGetProperty("id", out var id) ? id.GetString()! : Guid.NewGuid().ToString(); + var type = ExtractFirstCoding(resource, "type"); + + results.Add(new DocumentInfo + { + Id = docId, + Type = type?.display ?? type?.code ?? "Unknown", + ContentType = ExtractContentType(resource), + Title = ExtractDocumentTitle(resource) + }); + } + + return results; + } + + /// + public async Task GetDocumentContentAsync( + string documentId, + string accessToken, + CancellationToken cancellationToken = default) + { + var result = await _httpClient.ReadBinaryAsync(documentId, accessToken, cancellationToken); + + if (!result.IsFailure) return result.Value; + + _logger.LogWarning( + "Failed to fetch document content {DocumentId}: {Error}", + documentId, + result.Error?.Message); + + return null; + + } + + private static string? ExtractName(JsonElement json, string part) + { + if (!json.TryGetProperty("name", out var names)) return null; + + foreach (var name in names.EnumerateArray()) + { + switch (part) + { + case "given" when name.TryGetProperty("given", out var given): + { + var givenNames = given.EnumerateArray().Select(g => g.GetString() ?? "").ToList(); + return string.Join(" ", givenNames); + } + case "family" when name.TryGetProperty("family", out var family): + return family.GetString(); + } + } + + return null; + } + + private static DateOnly? ExtractDate(JsonElement json, string property) + { + if (!json.TryGetProperty(property, out var value)) return null; + if (DateOnly.TryParse(value.GetString(), out var date)) return date; + return null; + } + + private static (string code, string? system, string? display)? ExtractFirstCoding(JsonElement json, string property) + { + if (!json.TryGetProperty(property, out var codeableConcept)) return null; + if (!codeableConcept.TryGetProperty("coding", out var codings)) return null; + + foreach (var coding in codings.EnumerateArray()) + { + var code = coding.TryGetProperty("code", out var c) ? c.GetString() : null; + if (code is null) continue; + + var system = coding.TryGetProperty("system", out var s) ? s.GetString() : null; + var display = coding.TryGetProperty("display", out var d) ? d.GetString() : null; + + return (code, system, display); + } + + return null; + } + + private static string? ExtractClinicalStatus(JsonElement resource) + { + var coding = ExtractFirstCoding(resource, "clinicalStatus"); + return coding?.code; + } + + private static string? ExtractObservationValue(JsonElement resource) + { + if (resource.TryGetProperty("valueQuantity", out var quantity)) + { + return quantity.TryGetProperty("value", out var v) ? v.ToString() : null; + } + + return resource.TryGetProperty("valueString", out var str) + ? str.GetString() + : null; + } + + private static string? ExtractObservationUnit(JsonElement resource) + { + if (!resource.TryGetProperty("valueQuantity", out var quantity)) return null; + return quantity.TryGetProperty("unit", out var unit) ? unit.GetString() : null; + } + + private static string? ExtractContentType(JsonElement resource) + { + if (!resource.TryGetProperty("content", out var contents)) return null; + foreach (var content in contents.EnumerateArray()) + { + if (!content.TryGetProperty("attachment", out var attachment)) continue; + + if (attachment.TryGetProperty("contentType", out var ct)) + { + return ct.GetString(); + } + } + return null; + } + + private static string? ExtractDocumentTitle(JsonElement resource) + { + if (!resource.TryGetProperty("content", out var contents)) return null; + foreach (var content in contents.EnumerateArray()) + { + if (!content.TryGetProperty("attachment", out var attachment)) continue; + + if (attachment.TryGetProperty("title", out var title)) + { + return title.GetString(); + } + } + return null; + } +} diff --git a/apps/gateway/Gateway.API/Services/FhirDataAggregator.cs b/apps/gateway/Gateway.API/Services/FhirDataAggregator.cs index dc0a80f..020b757 100644 --- a/apps/gateway/Gateway.API/Services/FhirDataAggregator.cs +++ b/apps/gateway/Gateway.API/Services/FhirDataAggregator.cs @@ -1,25 +1,33 @@ +using Gateway.API.Configuration; using Gateway.API.Contracts; using Gateway.API.Models; +using Microsoft.Extensions.Options; namespace Gateway.API.Services; /// -/// Aggregates clinical data from Epic FHIR API by performing parallel queries +/// Aggregates clinical data from FHIR API by performing parallel queries /// for patient demographics, conditions, observations, procedures, and documents. /// public sealed class FhirDataAggregator : IFhirDataAggregator { - private readonly IEpicFhirClient _fhirClient; + private readonly IFhirClient _fhirClient; + private readonly ClinicalQueryOptions _options; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// - /// The Epic FHIR client for making API calls. + /// The FHIR client for making API calls. + /// Clinical query configuration options. /// Logger for diagnostic output. - public FhirDataAggregator(IEpicFhirClient fhirClient, ILogger logger) + public FhirDataAggregator( + IFhirClient fhirClient, + IOptions options, + ILogger logger) { _fhirClient = fhirClient; + _options = options.Value; _logger = logger; } @@ -31,14 +39,14 @@ public async Task AggregateClinicalDataAsync( { _logger.LogInformation("Aggregating clinical data for patient {PatientId}", patientId); - var sixMonthsAgo = DateOnly.FromDateTime(DateTime.UtcNow.AddMonths(-6)); - var oneYearAgo = DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-1)); + var observationSince = DateOnly.FromDateTime(DateTime.UtcNow.AddMonths(-_options.ObservationLookbackMonths)); + var procedureSince = DateOnly.FromDateTime(DateTime.UtcNow.AddMonths(-_options.ProcedureLookbackMonths)); // Parallel FHIR fetches for performance var patientTask = _fhirClient.GetPatientAsync(patientId, accessToken, cancellationToken); var conditionsTask = _fhirClient.SearchConditionsAsync(patientId, accessToken, cancellationToken); - var observationsTask = _fhirClient.SearchObservationsAsync(patientId, sixMonthsAgo, accessToken, cancellationToken); - var proceduresTask = _fhirClient.SearchProceduresAsync(patientId, oneYearAgo, accessToken, cancellationToken); + var observationsTask = _fhirClient.SearchObservationsAsync(patientId, observationSince, accessToken, cancellationToken); + var proceduresTask = _fhirClient.SearchProceduresAsync(patientId, procedureSince, accessToken, cancellationToken); var documentsTask = _fhirClient.SearchDocumentsAsync(patientId, accessToken, cancellationToken); await Task.WhenAll(patientTask, conditionsTask, observationsTask, proceduresTask, documentsTask); diff --git a/apps/gateway/Gateway.API/Services/IntelligenceClient.cs b/apps/gateway/Gateway.API/Services/IntelligenceClient.cs index 2331631..8c2b2d5 100644 --- a/apps/gateway/Gateway.API/Services/IntelligenceClient.cs +++ b/apps/gateway/Gateway.API/Services/IntelligenceClient.cs @@ -1,99 +1,86 @@ -using System.Net.Http.Json; using Gateway.API.Contracts; using Gateway.API.Models; namespace Gateway.API.Services; /// -/// HTTP client implementation for the Intelligence service. -/// Transforms clinical data into the format expected by the AI analysis endpoint. +/// STUB: Intelligence client that returns mock PA analysis data. +/// Production implementation will call the Intelligence service HTTP API. /// 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(HttpClient httpClient, ILogger logger) + public IntelligenceClient(ILogger logger) { - _httpClient = httpClient; _logger = logger; } /// - public async Task AnalyzeAsync( + public Task AnalyzeAsync( ClinicalBundle clinicalBundle, string procedureCode, CancellationToken cancellationToken = default) { _logger.LogInformation( - "Sending analysis request. PatientId={PatientId}, ProcedureCode={ProcedureCode}", - clinicalBundle.PatientId, procedureCode); + "STUB: Returning mock analysis for ProcedureCode={ProcedureCode}", + procedureCode); - var request = new + 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 { - patient_id = clinicalBundle.PatientId, - procedure_code = procedureCode, - clinical_data = new - { - patient = clinicalBundle.Patient is not null - ? new - { - name = clinicalBundle.Patient.FullName, - birth_date = clinicalBundle.Patient.BirthDate?.ToString("yyyy-MM-dd"), - gender = clinicalBundle.Patient.Gender, - member_id = clinicalBundle.Patient.MemberId - } - : null, - conditions = clinicalBundle.Conditions.Select(c => new - { - code = c.Code, - system = c.CodeSystem, - display = c.Display, - clinical_status = c.ClinicalStatus - }), - observations = clinicalBundle.Observations.Select(o => new + 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 { - code = o.Code, - system = o.CodeSystem, - display = o.Display, - value = o.Value, - unit = o.Unit - }), - procedures = clinicalBundle.Procedures.Select(p => new + CriterionId = "diagnosis_present", + Status = "MET", + Evidence = "STUB: Qualifying diagnosis code found", + Source = "Stub implementation", + Confidence = 0.95 + }, + new EvidenceItem { - code = p.Code, - system = p.CodeSystem, - display = p.Display, - status = p.Status - }) + 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") } }; - var response = await _httpClient.PostAsJsonAsync("/analyze", request, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - var error = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogError("Intelligence service error: {Status} - {Error}", response.StatusCode, error); - throw new HttpRequestException($"Intelligence service returned {response.StatusCode}"); - } - - var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - - if (result is null) - { - throw new InvalidOperationException("Intelligence service returned null response"); - } - - _logger.LogInformation( - "Analysis complete. Recommendation={Recommendation}, Confidence={Confidence}", - result.Recommendation, result.ConfidenceScore); - - return result; + return Task.FromResult(result); } } diff --git a/apps/gateway/Gateway.API/Services/PdfFormStamper.cs b/apps/gateway/Gateway.API/Services/PdfFormStamper.cs index cb1797b..cd95691 100644 --- a/apps/gateway/Gateway.API/Services/PdfFormStamper.cs +++ b/apps/gateway/Gateway.API/Services/PdfFormStamper.cs @@ -1,134 +1,40 @@ using Gateway.API.Contracts; using Gateway.API.Models; -using iText.Forms; -using iText.Kernel.Pdf; namespace Gateway.API.Services; /// -/// Uses iText to stamp PA form data onto PDF templates. -/// Falls back to generating a placeholder PDF if no template exists. +/// STUB: PDF form stamper that returns an empty byte array. +/// Production implementation will use iText to stamp PA data onto PDF templates. /// +/// +/// The iText NuGet dependency is kept for future production use. +/// public sealed class PdfFormStamper : IPdfFormStamper { private readonly ILogger _logger; - private readonly IWebHostEnvironment _environment; /// /// Initializes a new instance of the class. /// /// Logger for diagnostic output. - /// Web host environment for resolving content root path. - public PdfFormStamper(ILogger logger, IWebHostEnvironment environment) + public PdfFormStamper(ILogger logger) { _logger = logger; - _environment = environment; } /// - public async Task StampFormAsync( + public Task StampFormAsync( PAFormData formData, CancellationToken cancellationToken = default) { - _logger.LogInformation("Stamping PA form for patient {PatientName}", formData.PatientName); - - // Look for template in assets directory - var templatePath = Path.Combine( - _environment.ContentRootPath, - "..", "..", "..", "..", - "assets", "pdf-templates", - "mri-lumbar-pa-form.pdf"); - - // If template doesn't exist, generate a simple placeholder PDF - if (!File.Exists(templatePath)) - { - _logger.LogWarning("Template not found at {Path}, generating placeholder", templatePath); - return await GeneratePlaceholderPdfAsync(formData, cancellationToken); - } - - await using var outputStream = new MemoryStream(); - - using (var pdfReader = new PdfReader(templatePath)) - using (var pdfWriter = new PdfWriter(outputStream)) - using (var pdfDoc = new PdfDocument(pdfReader, pdfWriter)) - { - var form = PdfAcroForm.GetAcroForm(pdfDoc, true); - - // Map form fields using the field mappings from intelligence service - foreach (var (fieldName, value) in formData.FieldMappings) - { - var field = form.GetField(fieldName); - if (field is not null) - { - field.SetValue(value); - _logger.LogDebug("Set field {FieldName} = {Value}", fieldName, value); - } - else - { - _logger.LogWarning("Field {FieldName} not found in template", fieldName); - } - } - - // Flatten the form to prevent editing - form.FlattenFields(); - } - - return outputStream.ToArray(); - } - - private Task GeneratePlaceholderPdfAsync(PAFormData formData, CancellationToken cancellationToken) - { - // Generate a simple PDF with the form data for demo purposes - using var outputStream = new MemoryStream(); - using var writer = new PdfWriter(outputStream); - using var pdf = new PdfDocument(writer); - var document = new iText.Layout.Document(pdf); - - document.Add(new iText.Layout.Element.Paragraph("PRIOR AUTHORIZATION REQUEST") - .SetFontSize(18) - .SetBold()); - - document.Add(new iText.Layout.Element.Paragraph($"Generated by AuthScript") - .SetFontSize(10) - .SetItalic()); - - document.Add(new iText.Layout.Element.Paragraph("\n")); - - document.Add(new iText.Layout.Element.Paragraph("PATIENT INFORMATION") - .SetFontSize(14) - .SetBold()); - - document.Add(new iText.Layout.Element.Paragraph($"Name: {formData.PatientName}")); - document.Add(new iText.Layout.Element.Paragraph($"Date of Birth: {formData.PatientDob}")); - document.Add(new iText.Layout.Element.Paragraph($"Member ID: {formData.MemberId}")); - - document.Add(new iText.Layout.Element.Paragraph("\n")); - - document.Add(new iText.Layout.Element.Paragraph("PROCEDURE INFORMATION") - .SetFontSize(14) - .SetBold()); - - document.Add(new iText.Layout.Element.Paragraph($"Procedure Code: {formData.ProcedureCode}")); - document.Add(new iText.Layout.Element.Paragraph($"Diagnosis Codes: {string.Join(", ", formData.DiagnosisCodes)}")); - - document.Add(new iText.Layout.Element.Paragraph("\n")); - - document.Add(new iText.Layout.Element.Paragraph("CLINICAL SUMMARY") - .SetFontSize(14) - .SetBold()); - - document.Add(new iText.Layout.Element.Paragraph(formData.ClinicalSummary)); - - document.Add(new iText.Layout.Element.Paragraph("\n")); - - document.Add(new iText.Layout.Element.Paragraph($"AI Recommendation: {formData.Recommendation}") - .SetFontSize(12) - .SetBold()); - - document.Add(new iText.Layout.Element.Paragraph($"Confidence Score: {formData.ConfidenceScore:P0}")); - - document.Close(); - - return Task.FromResult(outputStream.ToArray()); + _logger.LogInformation("STUB: PDF stamping requested"); + + // STUB: Return empty array for now + // Production will use iText to: + // 1. Load PDF template from assets + // 2. Stamp form fields using formData.FieldMappings + // 3. Flatten and return the stamped PDF bytes + return Task.FromResult(Array.Empty()); } } diff --git a/apps/intelligence/openapi.json b/apps/intelligence/openapi.json index cc66680..55bc69a 100644 --- a/apps/intelligence/openapi.json +++ b/apps/intelligence/openapi.json @@ -12,7 +12,7 @@ "Analysis" ], "summary": "Analyze", - "description": "Analyze clinical data and generate PA form response.\n\nThis endpoint:\n1. Validates the procedure code against supported policies\n2. Extracts evidence from clinical data\n3. Evaluates against policy criteria\n4. Generates form field values", + "description": "Analyze clinical data and generate PA form response.\n\nSTUB IMPLEMENTATION: Always returns APPROVE with 1.0 confidence.\nProduction version would evaluate clinical data against payer policies.", "operationId": "analyze_analyze_post", "requestBody": { "content": { @@ -54,7 +54,7 @@ "Analysis" ], "summary": "Analyze With Documents", - "description": "Analyze clinical data with attached PDF documents.\n\nProcesses multipart form data including:\n- **patient_id**: Unique patient identifier\n- **procedure_code**: CPT/HCPCS code for the procedure\n- **clinical_data**: JSON string of clinical data\n- **documents**: PDF files containing clinical documentation\n\nReturns the same PA form response as the standard analyze endpoint.", + "description": "Analyze clinical data with attached PDF documents.\n\nSTUB IMPLEMENTATION: Documents are acknowledged but not processed.\nProduction version would extract text and analyze documents.", "operationId": "analyze_with_documents_analyze_with_documents_post", "parameters": [ { diff --git a/apps/intelligence/src/api/analyze.py b/apps/intelligence/src/api/analyze.py index 0960015..d1993e6 100644 --- a/apps/intelligence/src/api/analyze.py +++ b/apps/intelligence/src/api/analyze.py @@ -1,4 +1,8 @@ -"""Analysis endpoint for processing clinical data and generating PA form.""" +"""Analysis endpoint for processing clinical data and generating PA form. + +This is a stub implementation that returns APPROVE for all requests. +Production implementation would include policy evaluation and LLM reasoning. +""" from typing import Any @@ -7,12 +11,12 @@ from src.models.clinical_bundle import ClinicalBundle from src.models.pa_form import PAFormResponse -from src.policies.mri_lumbar import MRI_LUMBAR_POLICY -from src.reasoning.evidence_extractor import extract_evidence -from src.reasoning.form_generator import generate_form_data router = APIRouter() +# Supported procedure codes (MRI Lumbar Spine) +SUPPORTED_PROCEDURE_CODES = {"72148", "72149", "72158"} + class AnalyzeRequest(BaseModel): """Request payload for analysis endpoint.""" @@ -27,29 +31,40 @@ async def analyze(request: AnalyzeRequest) -> PAFormResponse: """ Analyze clinical data and generate PA form response. - This endpoint: - 1. Validates the procedure code against supported policies - 2. Extracts evidence from clinical data - 3. Evaluates against policy criteria - 4. Generates form field values + STUB IMPLEMENTATION: Always returns APPROVE with 1.0 confidence. + Production version would evaluate clinical data against payer policies. """ # Check if procedure is supported - if request.procedure_code not in MRI_LUMBAR_POLICY["procedure_codes"]: + 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 - clinical_bundle = ClinicalBundle.from_dict(request.patient_id, request.clinical_data) + bundle = ClinicalBundle.from_dict(request.patient_id, request.clinical_data) - # Extract evidence from clinical data - evidence = await extract_evidence(clinical_bundle, MRI_LUMBAR_POLICY) - - # Generate form data based on evidence - form_response = await generate_form_data(clinical_bundle, evidence, MRI_LUMBAR_POLICY) + # Validate required patient data + patient = bundle.patient + if not patient or not patient.birth_date: + raise HTTPException( + status_code=400, + detail="patient.birth_date is required", + ) - return form_response + # Build stub response + return PAFormResponse( + patient_name=patient.name, + patient_dob=patient.birth_date.isoformat(), + member_id=patient.member_id if patient.member_id else "Unknown", + diagnosis_codes=[c.code for c in bundle.conditions] if bundle.conditions else [], + procedure_code=request.procedure_code, + clinical_summary="Awaiting production configuration", + supporting_evidence=[], + recommendation="APPROVE", + confidence_score=1.0, + field_mappings=_build_field_mappings(bundle, request.procedure_code), + ) @router.post("/with-documents", response_model=PAFormResponse) @@ -62,13 +77,8 @@ async def analyze_with_documents( """ Analyze clinical data with attached PDF documents. - Processes multipart form data including: - - **patient_id**: Unique patient identifier - - **procedure_code**: CPT/HCPCS code for the procedure - - **clinical_data**: JSON string of clinical data - - **documents**: PDF files containing clinical documentation - - Returns the same PA form response as the standard analyze endpoint. + STUB IMPLEMENTATION: Documents are acknowledged but not processed. + Production version would extract text and analyze documents. """ import json @@ -78,15 +88,7 @@ async def analyze_with_documents( except json.JSONDecodeError as e: raise HTTPException(status_code=400, detail=f"Invalid clinical data JSON: {e}") - # Process documents if provided - document_texts: list[str] = [] - for doc in documents: - if doc.content_type == "application/pdf": - # In production, use LlamaParse here - content = await doc.read() - document_texts.append(f"[Document: {doc.filename}, {len(content)} bytes]") - - # Build request and process + # Build request and process (documents ignored in stub) request = AnalyzeRequest( patient_id=patient_id, procedure_code=procedure_code, @@ -94,3 +96,30 @@ async def analyze_with_documents( ) return await analyze(request) + + +def _build_field_mappings(bundle: ClinicalBundle, procedure_code: str) -> dict[str, str]: + """Build PDF field mappings from clinical bundle.""" + patient_name = bundle.patient.name if bundle.patient else "Unknown" + patient_dob = ( + bundle.patient.birth_date.isoformat() + if bundle.patient and bundle.patient.birth_date + else "Unknown" + ) + member_id = ( + bundle.patient.member_id + if bundle.patient and bundle.patient.member_id + else "Unknown" + ) + diagnosis_codes = ", ".join(c.code for c in bundle.conditions) if bundle.conditions else "" + + return { + "PatientName": patient_name, + "PatientDOB": patient_dob, + "MemberID": member_id, + "DiagnosisCodes": diagnosis_codes, + "ProcedureCode": procedure_code, + "ClinicalSummary": "Awaiting production configuration", + "ProviderSignature": "", + "Date": "", + } diff --git a/apps/intelligence/src/policies/mri_lumbar.py b/apps/intelligence/src/policies/example_policy.py similarity index 78% rename from apps/intelligence/src/policies/mri_lumbar.py rename to apps/intelligence/src/policies/example_policy.py index 98d8992..11f0a6a 100644 --- a/apps/intelligence/src/policies/mri_lumbar.py +++ b/apps/intelligence/src/policies/example_policy.py @@ -1,21 +1,34 @@ -"""MRI Lumbar Spine policy definition for Blue Cross.""" +"""Example policy definition for prior authorization. + +This module demonstrates the policy structure used by the PA system. +Each policy defines: +- Procedure codes (CPT) that trigger the policy +- Diagnosis codes (ICD-10) that qualify for coverage +- Criteria that must be met for approval +- Form field mappings for PDF generation + +Production implementations will load policies from a database or +configuration service based on payer and procedure. +""" from typing import Any -# MRI Lumbar Spine - Blue Cross Prior Authorization Policy -# This is a hardcoded policy definition for the demo -MRI_LUMBAR_POLICY: dict[str, Any] = { - "policy_id": "bcbs-mri-lumbar-2024", +# Example Policy - MRI Lumbar Spine +# This structure documents the expected policy format for future implementations +EXAMPLE_POLICY: dict[str, Any] = { + "policy_id": "example-mri-lumbar-2024", "policy_name": "MRI Lumbar Spine Prior Authorization", - "payer": "Blue Cross Blue Shield", + "payer": "Example Payer", "procedure_codes": ["72148", "72149", "72158"], # CPT codes for lumbar MRI "diagnosis_codes": { + # Primary diagnosis codes that directly qualify "primary": [ "M54.5", # Low back pain "M54.50", # Low back pain, site unspecified "M54.51", # Vertebrogenic low back pain "M54.52", # Low back pain due to muscle strain ], + # Supporting diagnosis codes that may qualify with additional criteria "supporting": [ "M51.16", # Intervertebral disc disorders with radiculopathy, lumbar "M51.17", # Intervertebral disc disorders with radiculopathy, lumbosacral @@ -74,6 +87,7 @@ "required": True, }, ], + # PDF form field mappings (field name in PDF -> data field) "form_field_mappings": { "patient_name": "PatientName", "patient_dob": "PatientDOB", diff --git a/apps/intelligence/src/reasoning/evidence_extractor.py b/apps/intelligence/src/reasoning/evidence_extractor.py index 6e381e1..5ce4801 100644 --- a/apps/intelligence/src/reasoning/evidence_extractor.py +++ b/apps/intelligence/src/reasoning/evidence_extractor.py @@ -1,243 +1,42 @@ -"""Evidence extraction from clinical data using LLM.""" +"""STUB: Evidence extraction from clinical data. + +Production implementation will use LLM and pattern matching to extract +evidence from clinical bundles and evaluate policy criteria. +""" -import re from typing import Any -from src.config import settings from src.models.clinical_bundle import ClinicalBundle from src.models.pa_form import EvidenceItem -EVIDENCE_EXTRACTION_PROMPT = """You are a clinical documentation specialist \ -reviewing medical records for prior authorization. - -PATIENT CONTEXT (Structured FHIR Data): -{structured_data} - -POLICY REQUIREMENTS for {procedure_name}: -{policy_criteria} - -TASK: -Extract evidence from the clinical data that supports or refutes each policy criterion. -For each criterion, determine: -1. Whether it is MET, NOT_MET, or UNCLEAR -2. The specific evidence found (quote the source if available) -3. Your confidence in this assessment (0.0 to 1.0) - -Respond in JSON format with an array of evidence items.""" - async def extract_evidence( clinical_bundle: ClinicalBundle, policy: dict[str, Any], ) -> list[EvidenceItem]: """ - Extract evidence from clinical data for each policy criterion. - - Uses LLM for complex reasoning, with pattern matching as fallback. - """ - evidence_items: list[EvidenceItem] = [] - - # Build structured data summary - structured_data = _build_structured_summary(clinical_bundle) - - # Check each criterion - for criterion in policy.get("criteria", []): - criterion_id = criterion["id"] - # description = criterion["description"] # Available for future LLM context - - # First try pattern matching for quick evidence - pattern_evidence = _check_patterns( - clinical_bundle, criterion.get("evidence_patterns", []) - ) - - if pattern_evidence: - evidence_items.append( - EvidenceItem( - criterion_id=criterion_id, - status="MET", - evidence=pattern_evidence, - source="Pattern matching on clinical data", - confidence=0.85, - ) - ) - elif criterion_id == "diagnosis_present": - # Special handling for diagnosis check - diagnosis_evidence = _check_diagnosis(clinical_bundle, policy) - evidence_items.append(diagnosis_evidence) - else: - # If no pattern match, use LLM or mark as unclear - if settings.llm_configured: - llm_evidence = await _extract_with_llm( - structured_data, criterion, policy - ) - evidence_items.append(llm_evidence) - else: - evidence_items.append( - EvidenceItem( - criterion_id=criterion_id, - status="UNCLEAR", - evidence="Unable to determine - LLM not configured", - source="System", - confidence=0.0, - ) - ) - - return evidence_items - - -def _build_structured_summary(bundle: ClinicalBundle) -> str: - """Build a text summary of structured clinical data.""" - parts = [] - - if bundle.patient: - parts.append(f"Patient: {bundle.patient.name}") - if bundle.patient.birth_date: - parts.append(f"DOB: {bundle.patient.birth_date}") - - if bundle.conditions: - conditions_str = ", ".join( - f"{c.code} ({c.display or 'Unknown'})" for c in bundle.conditions - ) - parts.append(f"Active Conditions: {conditions_str}") - - if bundle.procedures: - procedures_str = ", ".join( - f"{p.code} ({p.display or 'Unknown'})" for p in bundle.procedures - ) - parts.append(f"Recent Procedures: {procedures_str}") - - if bundle.observations: - parts.append(f"Observations: {len(bundle.observations)} results") - - return "\n".join(parts) - - -def _check_patterns(bundle: ClinicalBundle, patterns: list[str]) -> str | None: - """Check for evidence using regex patterns.""" - # Build searchable text from clinical data - search_text = _build_structured_summary(bundle).lower() + STUB: Return MET status for all policy criteria. - # Add any document text - for doc_text in bundle.document_texts: - search_text += "\n" + doc_text.lower() + Production implementation will: + 1. Build structured data summary from clinical bundle + 2. Check evidence patterns via regex matching + 3. Use LLM for complex criterion evaluation + 4. Return detailed evidence items with confidence scores - for pattern in patterns: - match = re.search(pattern, search_text, re.IGNORECASE) - if match: - # Return the matched text with context - start = max(0, match.start() - 50) - end = min(len(search_text), match.end() + 50) - return f"...{search_text[start:end]}..." + Args: + clinical_bundle: FHIR clinical data bundle + policy: Policy definition with criteria - return None - - -def _check_diagnosis(bundle: ClinicalBundle, policy: dict[str, Any]) -> EvidenceItem: - """Check if patient has a qualifying diagnosis code.""" - primary_codes = set(policy.get("diagnosis_codes", {}).get("primary", [])) - supporting_codes = set(policy.get("diagnosis_codes", {}).get("supporting", [])) - all_valid_codes = primary_codes | supporting_codes - - patient_codes = {c.code for c in bundle.conditions} - matching_codes = patient_codes & all_valid_codes - - if matching_codes: - matching_primary = matching_codes & primary_codes - if matching_primary: - return EvidenceItem( - criterion_id="diagnosis_present", - status="MET", - evidence=f"Primary diagnosis codes found: {', '.join(matching_primary)}", - source="FHIR Condition resources", - confidence=0.95, - ) - else: - return EvidenceItem( - criterion_id="diagnosis_present", - status="MET", - evidence=f"Supporting diagnosis codes found: {', '.join(matching_codes)}", - source="FHIR Condition resources", - confidence=0.85, - ) - else: - return EvidenceItem( - criterion_id="diagnosis_present", - status="NOT_MET", - evidence=( - f"No qualifying diagnosis codes found. " - f"Patient codes: {', '.join(patient_codes) or 'None'}" - ), - source="FHIR Condition resources", - confidence=0.90, - ) - - -async def _extract_with_llm( - structured_data: str, - criterion: dict[str, Any], - policy: dict[str, Any], -) -> EvidenceItem: - """Use LLM to extract evidence for a criterion.""" - try: - from src.llm_client import chat_completion - - system_prompt = "You are a clinical documentation specialist." - user_prompt = f"""Analyze this clinical data for evidence of: {criterion['description']} - -Clinical Data: -{structured_data} - -Respond with: -1. STATUS: MET, NOT_MET, or UNCLEAR -2. EVIDENCE: The specific text or finding that supports your conclusion -3. CONFIDENCE: A number from 0.0 to 1.0 - -Format your response as: -STATUS: -EVIDENCE: -CONFIDENCE: """ - - content = await chat_completion( - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=0, - max_tokens=500, - ) - - if not content: - raise ValueError("No response from LLM") - - # Parse response - status = "UNCLEAR" - evidence = "Unable to determine" - confidence = 0.5 - - for line in content.split("\n"): - if line.startswith("STATUS:"): - status_val = line.replace("STATUS:", "").strip().upper() - if status_val in ("MET", "NOT_MET", "UNCLEAR"): - status = status_val - elif line.startswith("EVIDENCE:"): - evidence = line.replace("EVIDENCE:", "").strip() - elif line.startswith("CONFIDENCE:"): - try: - confidence = float(line.replace("CONFIDENCE:", "").strip()) - except ValueError: - pass - - return EvidenceItem( - criterion_id=criterion["id"], - status=status, # type: ignore - evidence=evidence, - source="LLM analysis", - confidence=confidence, - ) - - except Exception as e: - return EvidenceItem( + Returns: + List of evidence items, one per policy criterion + """ + return [ + EvidenceItem( criterion_id=criterion["id"], - status="UNCLEAR", - evidence=f"LLM analysis failed: {str(e)}", - source="System", - confidence=0.0, + status="MET", + evidence="STUB: Evidence would be extracted from clinical data", + source="Stub implementation", + confidence=0.90, ) + for criterion in policy.get("criteria", []) + ] diff --git a/apps/intelligence/src/reasoning/form_generator.py b/apps/intelligence/src/reasoning/form_generator.py index c03c0a3..07dfbf4 100644 --- a/apps/intelligence/src/reasoning/form_generator.py +++ b/apps/intelligence/src/reasoning/form_generator.py @@ -1,9 +1,11 @@ -"""Generate PA form data from extracted evidence.""" +"""STUB: Generate PA form data from extracted evidence. -from datetime import date -from typing import Any, Literal +Production implementation will calculate recommendations based on +evidence and generate clinical summaries using LLM. +""" + +from typing import Any -from src.config import settings from src.models.clinical_bundle import ClinicalBundle from src.models.pa_form import EvidenceItem, PAFormResponse @@ -14,12 +16,21 @@ async def generate_form_data( policy: dict[str, Any], ) -> PAFormResponse: """ - Generate complete PA form response from clinical data and evidence. - """ - # Calculate recommendation based on evidence - recommendation, confidence = _calculate_recommendation(evidence, policy) + STUB: Return APPROVE recommendation with high confidence. + + Production implementation will: + 1. Calculate recommendation based on evidence (APPROVE/NEED_INFO/MANUAL_REVIEW) + 2. Generate clinical summary via LLM or template + 3. Build PDF field mappings from policy configuration + + Args: + clinical_bundle: FHIR clinical data bundle + evidence: Extracted evidence items + policy: Policy definition with field mappings - # Extract patient info + Returns: + Complete PA form response ready for PDF stamping + """ patient_name = "Unknown" patient_dob = "Unknown" member_id = "Unknown" @@ -31,223 +42,29 @@ async def generate_form_data( if clinical_bundle.patient.member_id: member_id = clinical_bundle.patient.member_id - # Get diagnosis codes diagnosis_codes = [c.code for c in clinical_bundle.conditions] if not diagnosis_codes: diagnosis_codes = ["Unknown"] - # Generate clinical summary - clinical_summary = await _generate_clinical_summary( - clinical_bundle, evidence, policy - ) - - # Build field mappings for PDF form - field_mappings = _build_field_mappings( - patient_name, - patient_dob, - member_id, - diagnosis_codes, - policy.get("procedure_codes", ["72148"])[0], - clinical_summary, - policy, - ) + procedure_codes = policy.get("procedure_codes") or ["72148"] + procedure_code = procedure_codes[0] return PAFormResponse( patient_name=patient_name, patient_dob=patient_dob, member_id=member_id, diagnosis_codes=diagnosis_codes, - procedure_code=policy.get("procedure_codes", ["72148"])[0], - clinical_summary=clinical_summary, + procedure_code=procedure_code, + clinical_summary="STUB: Clinical summary would be generated from evidence.", supporting_evidence=evidence, - recommendation=recommendation, - confidence_score=confidence, - field_mappings=field_mappings, + recommendation="APPROVE", + confidence_score=0.95, + field_mappings={ + "PatientName": patient_name, + "PatientDOB": patient_dob, + "MemberID": member_id, + "PrimaryDiagnosis": diagnosis_codes[0] if diagnosis_codes else "Unknown", + "ProcedureCode": procedure_code, + "ClinicalJustification": "STUB: Clinical justification", + }, ) - - -Recommendation = Literal["APPROVE", "NEED_INFO", "MANUAL_REVIEW"] - - -def _calculate_recommendation( - evidence: list[EvidenceItem], - policy: dict[str, Any], -) -> tuple[Recommendation, float]: - """Calculate recommendation and confidence from evidence.""" - criteria = policy.get("criteria", []) - required_criteria = [c for c in criteria if c.get("required", False)] - - # Check for neurological red flags (bypasses conservative therapy) - has_red_flags = False - for item in evidence: - if item.criterion_id == "neurological_symptoms" and item.status == "MET": - has_red_flags = True - break - - # Count met required criteria - met_required = 0 - total_confidence = 0.0 - - for criterion in required_criteria: - criterion_id = criterion["id"] - - # Skip conservative therapy if red flags present - if criterion_id == "conservative_therapy" and has_red_flags: - met_required += 1 - total_confidence += 0.9 - continue - - # Find evidence for this criterion - for item in evidence: - if item.criterion_id == criterion_id: - if item.status == "MET": - met_required += 1 - total_confidence += item.confidence - break - - # Calculate overall confidence - num_required = len(required_criteria) - if num_required > 0: - avg_confidence = total_confidence / num_required - met_ratio = met_required / num_required - else: - avg_confidence = 0.5 - met_ratio = 0.0 - - # Determine recommendation - if met_ratio == 1.0 and avg_confidence >= 0.8: - return "APPROVE", avg_confidence - elif met_ratio >= 0.5: - return "MANUAL_REVIEW", avg_confidence * 0.8 - else: - return "NEED_INFO", avg_confidence * 0.6 - - -async def _generate_clinical_summary( - clinical_bundle: ClinicalBundle, - evidence: list[EvidenceItem], - policy: dict[str, Any], -) -> str: - """Generate a clinical summary for the PA form.""" - # Try LLM generation first - if settings.llm_configured: - return await _generate_summary_with_llm(clinical_bundle, evidence, policy) - - # Fallback to template-based summary - return _generate_template_summary(clinical_bundle, evidence, policy) - - -async def _generate_summary_with_llm( - clinical_bundle: ClinicalBundle, - evidence: list[EvidenceItem], - policy: dict[str, Any], -) -> str: - """Generate clinical summary using LLM.""" - try: - from src.llm_client import chat_completion - - # Build evidence summary - evidence_text = "\n".join( - f"- {item.criterion_id}: {item.status} - {item.evidence}" - for item in evidence - ) - - system_prompt = ( - "You are a clinical documentation specialist " - "writing prior authorization justifications." - ) - user_prompt = f"""Write a 2-3 sentence clinical justification for medical necessity \ -of an MRI Lumbar Spine. - -Patient Information: -- Name: {clinical_bundle.patient.name if clinical_bundle.patient else 'Unknown'} -- Conditions: {', '.join(c.display or c.code for c in clinical_bundle.conditions)} - -Evidence Found: -{evidence_text} - -Write a professional medical necessity statement suitable for a prior authorization form. -Focus on the clinical need and supporting evidence. Be concise.""" - - content = await chat_completion( - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=0.3, - max_tokens=200, - ) - - return content or _generate_template_summary(clinical_bundle, evidence, policy) - - except Exception: - return _generate_template_summary(clinical_bundle, evidence, policy) - - -def _generate_template_summary( - clinical_bundle: ClinicalBundle, - evidence: list[EvidenceItem], - policy: dict[str, Any], -) -> str: - """Generate a template-based clinical summary.""" - conditions = [c.display or c.code for c in clinical_bundle.conditions] - condition_text = ", ".join(conditions) if conditions else "lumbar spine condition" - - # Find key evidence - therapy_evidence = next( - (e for e in evidence if e.criterion_id == "conservative_therapy" and e.status == "MET"), - None, - ) - neuro_evidence = next( - (e for e in evidence if e.criterion_id == "neurological_symptoms" and e.status == "MET"), - None, - ) - - if neuro_evidence: - return ( - f"Patient presents with {condition_text} and neurological symptoms requiring " - f"urgent MRI evaluation. {neuro_evidence.evidence[:100]}..." - ) - elif therapy_evidence: - return ( - f"Patient has {condition_text} with documented failure of conservative " - f"therapy. MRI is medically necessary to evaluate for structural abnormalities " - f"and guide further treatment." - ) - else: - return ( - f"Patient presents with {condition_text}. MRI Lumbar Spine is requested " - f"for diagnostic evaluation and treatment planning." - ) - - -def _build_field_mappings( - patient_name: str, - patient_dob: str, - member_id: str, - diagnosis_codes: list[str], - procedure_code: str, - clinical_summary: str, - policy: dict[str, Any], -) -> dict[str, str]: - """Build PDF field mappings from form data.""" - mappings = policy.get("form_field_mappings", {}) - - result = {} - - if "patient_name" in mappings: - result[mappings["patient_name"]] = patient_name - if "patient_dob" in mappings: - result[mappings["patient_dob"]] = patient_dob - if "member_id" in mappings: - result[mappings["member_id"]] = member_id - if "diagnosis_primary" in mappings and diagnosis_codes: - result[mappings["diagnosis_primary"]] = diagnosis_codes[0] - if "diagnosis_secondary" in mappings and len(diagnosis_codes) > 1: - result[mappings["diagnosis_secondary"]] = ", ".join(diagnosis_codes[1:]) - if "procedure_code" in mappings: - result[mappings["procedure_code"]] = procedure_code - if "clinical_summary" in mappings: - result[mappings["clinical_summary"]] = clinical_summary - if "date_of_service" in mappings: - result[mappings["date_of_service"]] = date.today().isoformat() - - return result diff --git a/apps/intelligence/src/tests/test_analyze.py b/apps/intelligence/src/tests/test_analyze.py new file mode 100644 index 0000000..690b55a --- /dev/null +++ b/apps/intelligence/src/tests/test_analyze.py @@ -0,0 +1,87 @@ +"""Tests for analyze API endpoint stub implementation.""" + +import pytest +from fastapi import HTTPException + +from src.api.analyze import AnalyzeRequest, analyze + + +@pytest.fixture +def valid_request() -> AnalyzeRequest: + """Create a valid analyze request.""" + return AnalyzeRequest( + patient_id="test-123", + procedure_code="72148", + clinical_data={ + "patient": { + "name": "John Doe", + "birth_date": "1980-05-15", + "member_id": "MEM-001", + }, + "conditions": [ + {"code": "M54.5", "display": "Low back pain"}, + ], + }, + ) + + +@pytest.mark.asyncio +async def test_analyze_returns_approve(valid_request: AnalyzeRequest) -> None: + """Stub should return APPROVE recommendation.""" + result = await analyze(valid_request) + + assert result.recommendation == "APPROVE" + assert result.confidence_score == 1.0 + + +@pytest.mark.asyncio +async def test_analyze_extracts_patient_info(valid_request: AnalyzeRequest) -> None: + """Stub should extract patient information.""" + result = await analyze(valid_request) + + assert result.patient_name == "John Doe" + assert result.patient_dob == "1980-05-15" + assert result.member_id == "MEM-001" + + +@pytest.mark.asyncio +async def test_analyze_rejects_unsupported_procedure() -> None: + """Stub should reject unsupported procedure codes.""" + 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 + + +@pytest.mark.asyncio +async def test_analyze_requires_patient_dob() -> None: + """Stub should require patient birth_date.""" + request = AnalyzeRequest( + patient_id="test", + procedure_code="72148", + clinical_data={"patient": {"name": "Test"}}, + ) + + with pytest.raises(HTTPException) as exc_info: + await analyze(request) + + assert exc_info.value.status_code == 400 + assert "birth_date" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_analyze_builds_field_mappings(valid_request: AnalyzeRequest) -> None: + """Stub should include PDF field mappings.""" + result = await analyze(valid_request) + + assert "PatientName" in result.field_mappings + assert "PatientDOB" in result.field_mappings + assert "ProcedureCode" in result.field_mappings + assert result.field_mappings["PatientName"] == "John Doe" diff --git a/apps/intelligence/src/tests/test_evidence_extractor.py b/apps/intelligence/src/tests/test_evidence_extractor.py index f6a8c10..06006fa 100644 --- a/apps/intelligence/src/tests/test_evidence_extractor.py +++ b/apps/intelligence/src/tests/test_evidence_extractor.py @@ -1,9 +1,8 @@ -"""Tests for evidence extraction.""" +"""Tests for evidence extractor stub implementation.""" import pytest from src.models.clinical_bundle import ClinicalBundle, Condition, PatientInfo -from src.policies.mri_lumbar import MRI_LUMBAR_POLICY from src.reasoning.evidence_extractor import extract_evidence @@ -11,81 +10,55 @@ def sample_bundle() -> ClinicalBundle: """Create a sample clinical bundle for testing.""" return ClinicalBundle( - patient_id="test-001", - patient=PatientInfo( - name="John Doe", - member_id="MEM123456", - ), - conditions=[ - Condition( - code="M54.5", - display="Low back pain", - clinical_status="active", - ), - ], + patient_id="test-123", + patient=PatientInfo(name="Test Patient"), + conditions=[Condition(code="M54.5", display="Low back pain")], ) @pytest.fixture -def bundle_with_radiculopathy() -> ClinicalBundle: - """Bundle with neurological symptoms.""" - return ClinicalBundle( - patient_id="test-002", - patient=PatientInfo(name="Jane Smith"), - conditions=[ - Condition( - code="M51.16", - display="Intervertebral disc disorder with radiculopathy, lumbar region", - clinical_status="active", - ), +def sample_policy() -> dict: + """Create a sample policy with criteria.""" + return { + "id": "test-policy", + "criteria": [ + {"id": "crit-1", "description": "Test criterion 1"}, + {"id": "crit-2", "description": "Test criterion 2"}, ], - ) + } @pytest.mark.asyncio -async def test_diagnosis_check_with_primary_code(sample_bundle: ClinicalBundle) -> None: - """Test that primary diagnosis codes are detected.""" - evidence = await extract_evidence(sample_bundle, MRI_LUMBAR_POLICY) - - diagnosis_evidence = next( - (e for e in evidence if e.criterion_id == "diagnosis_present"), None - ) +async def test_extract_evidence_returns_met_for_all_criteria( + sample_bundle: ClinicalBundle, + sample_policy: dict, +) -> None: + """Stub should return MET status for all policy criteria.""" + evidence = await extract_evidence(sample_bundle, sample_policy) - assert diagnosis_evidence is not None - assert diagnosis_evidence.status == "MET" - assert "M54.5" in diagnosis_evidence.evidence + assert len(evidence) == 2 + assert all(e.status == "MET" for e in evidence) + assert evidence[0].criterion_id == "crit-1" + assert evidence[1].criterion_id == "crit-2" @pytest.mark.asyncio -async def test_diagnosis_check_with_supporting_code( - bundle_with_radiculopathy: ClinicalBundle, -) -> None: - """Test that supporting diagnosis codes are detected.""" - evidence = await extract_evidence(bundle_with_radiculopathy, MRI_LUMBAR_POLICY) +async def test_extract_evidence_empty_criteria() -> None: + """Stub should return empty list when no criteria defined.""" + bundle = ClinicalBundle(patient_id="test") + policy: dict = {"id": "empty", "criteria": []} - diagnosis_evidence = next( - (e for e in evidence if e.criterion_id == "diagnosis_present"), None - ) + evidence = await extract_evidence(bundle, policy) - assert diagnosis_evidence is not None - assert diagnosis_evidence.status == "MET" + assert evidence == [] @pytest.mark.asyncio -async def test_missing_diagnosis() -> None: - """Test behavior when no qualifying diagnosis is present.""" - bundle = ClinicalBundle( - patient_id="test-003", - conditions=[ - Condition(code="Z00.00", display="General health exam"), - ], - ) - - evidence = await extract_evidence(bundle, MRI_LUMBAR_POLICY) - - diagnosis_evidence = next( - (e for e in evidence if e.criterion_id == "diagnosis_present"), None - ) +async def test_extract_evidence_confidence_score( + sample_bundle: ClinicalBundle, + sample_policy: dict, +) -> None: + """Stub should return 0.90 confidence for all items.""" + evidence = await extract_evidence(sample_bundle, sample_policy) - assert diagnosis_evidence is not None - assert diagnosis_evidence.status == "NOT_MET" + assert all(e.confidence == 0.90 for e in evidence) diff --git a/apps/intelligence/src/tests/test_form_generator.py b/apps/intelligence/src/tests/test_form_generator.py index 67b96e8..8f5da36 100644 --- a/apps/intelligence/src/tests/test_form_generator.py +++ b/apps/intelligence/src/tests/test_form_generator.py @@ -1,457 +1,123 @@ -"""Tests for form generator.""" +"""Tests for form generator stub implementation.""" from datetime import date -from unittest.mock import AsyncMock, patch import pytest from src.models.clinical_bundle import ClinicalBundle, Condition, PatientInfo from src.models.pa_form import EvidenceItem -from src.reasoning.form_generator import ( - _build_field_mappings, - _calculate_recommendation, - generate_form_data, -) - - -@pytest.fixture -def sample_policy() -> dict: - """Create a sample policy for testing.""" - return { - "criteria": [ - {"id": "conservative_therapy", "required": True}, - {"id": "neurological_symptoms", "required": True}, - ], - "procedure_codes": ["72148"], - "form_field_mappings": { - "patient_name": "PatientFullName", - "patient_dob": "DateOfBirth", - "member_id": "MemberID", - "diagnosis_primary": "PrimaryDiagnosis", - "diagnosis_secondary": "SecondaryDiagnosis", - "procedure_code": "ProcedureCode", - "clinical_summary": "ClinicalNotes", - "date_of_service": "ServiceDate", - }, - } +from src.reasoning.form_generator import generate_form_data @pytest.fixture def sample_bundle() -> ClinicalBundle: """Create a sample clinical bundle for testing.""" return ClinicalBundle( - patient_id="test-001", + patient_id="test-123", patient=PatientInfo( name="John Doe", birth_date=date(1980, 5, 15), - member_id="MEM123456", + member_id="MEM-001", ), - conditions=[ - Condition( - code="M54.5", - display="Low back pain", - clinical_status="active", - ), - ], + conditions=[Condition(code="M54.5", display="Low back pain")], ) @pytest.fixture -def evidence_all_met() -> list[EvidenceItem]: - """Evidence with all criteria met.""" +def sample_evidence() -> list[EvidenceItem]: + """Create sample evidence items.""" return [ EvidenceItem( - criterion_id="conservative_therapy", - status="MET", - evidence="Physical therapy completed for 6 weeks", - source="clinical_notes", - confidence=0.9, - ), - EvidenceItem( - criterion_id="neurological_symptoms", + criterion_id="crit-1", status="MET", - evidence="Radiculopathy with weakness", - source="clinical_notes", - confidence=0.85, - ), - ] - - -@pytest.fixture -def evidence_partial_met() -> list[EvidenceItem]: - """Evidence with partial criteria met.""" - return [ - EvidenceItem( - criterion_id="conservative_therapy", - status="MET", - evidence="Physical therapy completed", - source="clinical_notes", - confidence=0.8, - ), - EvidenceItem( - criterion_id="neurological_symptoms", - status="NOT_MET", - evidence="No neurological symptoms documented", - source="clinical_notes", - confidence=0.7, - ), + evidence="Test evidence", + source="Test", + confidence=0.90, + ) ] @pytest.fixture -def evidence_none_met() -> list[EvidenceItem]: - """Evidence with no criteria met.""" - return [ - EvidenceItem( - criterion_id="conservative_therapy", - status="NOT_MET", - evidence="No conservative therapy documented", - source="clinical_notes", - confidence=0.6, - ), - EvidenceItem( - criterion_id="neurological_symptoms", - status="NOT_MET", - evidence="No neurological symptoms", - source="clinical_notes", - confidence=0.5, - ), - ] - - -class TestCalculateRecommendation: - """Tests for _calculate_recommendation function.""" - - def test_approve_when_all_required_criteria_met( - self, sample_policy: dict, evidence_all_met: list[EvidenceItem] - ) -> None: - """Should return APPROVE when all required criteria are MET with high confidence.""" - recommendation, confidence = _calculate_recommendation( - evidence_all_met, sample_policy - ) - - assert recommendation == "APPROVE" - assert confidence >= 0.8 - - def test_manual_review_when_partial_criteria_met( - self, sample_policy: dict, evidence_partial_met: list[EvidenceItem] - ) -> None: - """Should return MANUAL_REVIEW when at least 50% criteria are met.""" - recommendation, confidence = _calculate_recommendation( - evidence_partial_met, sample_policy - ) - - assert recommendation == "MANUAL_REVIEW" - assert 0.0 <= confidence <= 1.0 - - def test_need_info_when_insufficient_criteria( - self, sample_policy: dict, evidence_none_met: list[EvidenceItem] - ) -> None: - """Should return NEED_INFO when less than 50% criteria are met.""" - recommendation, confidence = _calculate_recommendation( - evidence_none_met, sample_policy - ) - - assert recommendation == "NEED_INFO" - assert 0.0 <= confidence <= 1.0 - - def test_neurological_red_flags_bypass_conservative_therapy( - self, sample_policy: dict - ) -> None: - """Neurological symptoms should bypass conservative therapy requirement.""" - evidence = [ - EvidenceItem( - criterion_id="neurological_symptoms", - status="MET", - evidence="Severe radiculopathy with motor weakness", - source="clinical_notes", - confidence=0.95, - ), - EvidenceItem( - criterion_id="conservative_therapy", - status="NOT_MET", - evidence="No conservative therapy documented", - source="clinical_notes", - confidence=0.5, - ), - ] - - recommendation, confidence = _calculate_recommendation(evidence, sample_policy) - - # Should approve because neuro symptoms bypass conservative therapy requirement - assert recommendation == "APPROVE" - assert confidence >= 0.8 - - def test_empty_evidence_list(self, sample_policy: dict) -> None: - """Should return NEED_INFO with empty evidence.""" - recommendation, confidence = _calculate_recommendation([], sample_policy) - - assert recommendation == "NEED_INFO" - assert 0.0 <= confidence <= 1.0 - - def test_no_required_criteria_in_policy(self) -> None: - """Should handle policy with no required criteria.""" - policy = {"criteria": [{"id": "optional_criterion", "required": False}]} - evidence = [ - EvidenceItem( - criterion_id="optional_criterion", - status="MET", - evidence="Optional criterion met", - source="clinical_notes", - confidence=0.9, - ) - ] - - recommendation, confidence = _calculate_recommendation(evidence, policy) - - # With no required criteria, met_ratio is 0.0 - assert recommendation == "NEED_INFO" - - def test_empty_criteria_in_policy(self) -> None: - """Should handle policy with empty criteria list.""" - policy: dict = {"criteria": []} - - recommendation, confidence = _calculate_recommendation([], policy) - - assert recommendation == "NEED_INFO" - assert confidence == 0.3 # 0.5 * 0.6 - - -class TestBuildFieldMappings: - """Tests for _build_field_mappings function.""" - - def test_maps_all_fields_correctly(self, sample_policy: dict) -> None: - """Should map all available fields to PDF form fields.""" - result = _build_field_mappings( - patient_name="John Doe", - patient_dob="1980-05-15", - member_id="MEM123456", - diagnosis_codes=["M54.5", "M51.16"], - procedure_code="72148", - clinical_summary="Test clinical summary", - policy=sample_policy, - ) - - assert result["PatientFullName"] == "John Doe" - assert result["DateOfBirth"] == "1980-05-15" - assert result["MemberID"] == "MEM123456" - assert result["PrimaryDiagnosis"] == "M54.5" - assert result["SecondaryDiagnosis"] == "M51.16" - assert result["ProcedureCode"] == "72148" - assert result["ClinicalNotes"] == "Test clinical summary" - assert result["ServiceDate"] == date.today().isoformat() - - def test_single_diagnosis_code(self, sample_policy: dict) -> None: - """Should handle single diagnosis code without secondary.""" - result = _build_field_mappings( - patient_name="Jane Smith", - patient_dob="1990-01-01", - member_id="MEM789", - diagnosis_codes=["M54.5"], - procedure_code="72148", - clinical_summary="Summary", - policy=sample_policy, - ) - - assert result["PrimaryDiagnosis"] == "M54.5" - assert "SecondaryDiagnosis" not in result - - def test_empty_diagnosis_codes(self, sample_policy: dict) -> None: - """Should handle empty diagnosis codes list.""" - result = _build_field_mappings( - patient_name="Jane Smith", - patient_dob="1990-01-01", - member_id="MEM789", - diagnosis_codes=[], - procedure_code="72148", - clinical_summary="Summary", - policy=sample_policy, - ) - - assert "PrimaryDiagnosis" not in result - assert "SecondaryDiagnosis" not in result - - def test_no_field_mappings_in_policy(self) -> None: - """Should return empty dict when no mappings configured.""" - policy: dict = {"criteria": [], "procedure_codes": ["72148"]} - - result = _build_field_mappings( - patient_name="John Doe", - patient_dob="1980-05-15", - member_id="MEM123", - diagnosis_codes=["M54.5"], - procedure_code="72148", - clinical_summary="Summary", - policy=policy, - ) - - assert result == {} - - def test_partial_field_mappings(self) -> None: - """Should only map fields that are configured.""" - policy = { - "form_field_mappings": { - "patient_name": "Name", - "patient_dob": "DOB", - } - } - - result = _build_field_mappings( - patient_name="John Doe", - patient_dob="1980-05-15", - member_id="MEM123", - diagnosis_codes=["M54.5"], - procedure_code="72148", - clinical_summary="Summary", - policy=policy, - ) - - assert result == {"Name": "John Doe", "DOB": "1980-05-15"} +def sample_policy() -> dict: + """Create a sample policy.""" + return { + "id": "test-policy", + "procedure_codes": ["72148"], + } - def test_multiple_secondary_diagnoses(self, sample_policy: dict) -> None: - """Should join multiple secondary diagnoses with comma.""" - result = _build_field_mappings( - patient_name="John Doe", - patient_dob="1980-05-15", - member_id="MEM123", - diagnosis_codes=["M54.5", "M51.16", "G89.4"], - procedure_code="72148", - clinical_summary="Summary", - policy=sample_policy, - ) - assert result["PrimaryDiagnosis"] == "M54.5" - assert result["SecondaryDiagnosis"] == "M51.16, G89.4" +@pytest.mark.asyncio +async def test_generate_form_data_returns_approve( + sample_bundle: ClinicalBundle, + sample_evidence: list[EvidenceItem], + sample_policy: dict, +) -> None: + """Stub should return APPROVE recommendation.""" + result = await generate_form_data(sample_bundle, sample_evidence, sample_policy) + assert result.recommendation == "APPROVE" + assert result.confidence_score == 0.95 -class TestGenerateFormData: - """Tests for generate_form_data function.""" - @pytest.mark.asyncio - async def test_returns_correct_structure( - self, - sample_bundle: ClinicalBundle, - sample_policy: dict, - evidence_all_met: list[EvidenceItem], - ) -> None: - """Should return PAFormResponse with correct structure.""" - with patch( - "src.reasoning.form_generator._generate_clinical_summary", - new_callable=AsyncMock, - return_value="Generated clinical summary", - ): - result = await generate_form_data( - sample_bundle, evidence_all_met, sample_policy - ) +@pytest.mark.asyncio +async def test_generate_form_data_extracts_patient_info( + sample_bundle: ClinicalBundle, + sample_evidence: list[EvidenceItem], + sample_policy: dict, +) -> None: + """Stub should extract patient information from bundle.""" + result = await generate_form_data(sample_bundle, sample_evidence, sample_policy) - assert result.patient_name == "John Doe" - assert result.patient_dob == "1980-05-15" - assert result.member_id == "MEM123456" - assert result.diagnosis_codes == ["M54.5"] - assert result.procedure_code == "72148" - assert result.clinical_summary == "Generated clinical summary" - assert result.supporting_evidence == evidence_all_met - assert result.recommendation == "APPROVE" - assert result.confidence_score >= 0.8 - assert "PatientFullName" in result.field_mappings + assert result.patient_name == "John Doe" + assert result.patient_dob == "1980-05-15" + assert result.member_id == "MEM-001" - @pytest.mark.asyncio - async def test_missing_patient_info( - self, sample_policy: dict, evidence_all_met: list[EvidenceItem] - ) -> None: - """Should handle missing patient information gracefully.""" - bundle = ClinicalBundle(patient_id="test-002", patient=None, conditions=[]) - with patch( - "src.reasoning.form_generator._generate_clinical_summary", - new_callable=AsyncMock, - return_value="Summary", - ): - result = await generate_form_data(bundle, evidence_all_met, sample_policy) +@pytest.mark.asyncio +async def test_generate_form_data_extracts_diagnosis( + sample_bundle: ClinicalBundle, + sample_evidence: list[EvidenceItem], + sample_policy: dict, +) -> None: + """Stub should extract diagnosis codes from bundle.""" + result = await generate_form_data(sample_bundle, sample_evidence, sample_policy) - assert result.patient_name == "Unknown" - assert result.patient_dob == "Unknown" - assert result.member_id == "Unknown" - assert result.diagnosis_codes == ["Unknown"] + assert result.diagnosis_codes == ["M54.5"] - @pytest.mark.asyncio - async def test_patient_without_birth_date( - self, sample_policy: dict, evidence_all_met: list[EvidenceItem] - ) -> None: - """Should handle patient without birth date.""" - bundle = ClinicalBundle( - patient_id="test-003", - patient=PatientInfo(name="Jane Smith", birth_date=None, member_id=None), - conditions=[], - ) - with patch( - "src.reasoning.form_generator._generate_clinical_summary", - new_callable=AsyncMock, - return_value="Summary", - ): - result = await generate_form_data(bundle, evidence_all_met, sample_policy) +@pytest.mark.asyncio +async def test_generate_form_data_uses_policy_procedure_code( + sample_bundle: ClinicalBundle, + sample_evidence: list[EvidenceItem], + sample_policy: dict, +) -> None: + """Stub should use procedure code from policy.""" + result = await generate_form_data(sample_bundle, sample_evidence, sample_policy) - assert result.patient_name == "Jane Smith" - assert result.patient_dob == "Unknown" - assert result.member_id == "Unknown" + assert result.procedure_code == "72148" - @pytest.mark.asyncio - async def test_empty_conditions_list( - self, sample_policy: dict, evidence_all_met: list[EvidenceItem] - ) -> None: - """Should default to Unknown when no conditions.""" - bundle = ClinicalBundle( - patient_id="test-004", - patient=PatientInfo(name="Test Patient"), - conditions=[], - ) - with patch( - "src.reasoning.form_generator._generate_clinical_summary", - new_callable=AsyncMock, - return_value="Summary", - ): - result = await generate_form_data(bundle, evidence_all_met, sample_policy) +@pytest.mark.asyncio +async def test_generate_form_data_handles_missing_patient() -> None: + """Stub should handle missing patient data gracefully.""" + bundle = ClinicalBundle(patient_id="test") + evidence: list[EvidenceItem] = [] + policy: dict = {"procedure_codes": ["72148"]} - assert result.diagnosis_codes == ["Unknown"] + result = await generate_form_data(bundle, evidence, policy) - @pytest.mark.asyncio - async def test_uses_default_procedure_code( - self, sample_bundle: ClinicalBundle, evidence_all_met: list[EvidenceItem] - ) -> None: - """Should use default procedure code when not in policy.""" - policy: dict = {"criteria": [], "form_field_mappings": {}} + assert result.patient_name == "Unknown" + assert result.patient_dob == "Unknown" + assert result.member_id == "Unknown" - with patch( - "src.reasoning.form_generator._generate_clinical_summary", - new_callable=AsyncMock, - return_value="Summary", - ): - result = await generate_form_data(sample_bundle, evidence_all_met, policy) - assert result.procedure_code == "72148" +@pytest.mark.asyncio +async def test_generate_form_data_handles_empty_procedure_codes() -> None: + """Stub should use default procedure code when list is empty.""" + bundle = ClinicalBundle(patient_id="test") + evidence: list[EvidenceItem] = [] + policy: dict = {"procedure_codes": []} - @pytest.mark.asyncio - async def test_includes_field_mappings( - self, - sample_bundle: ClinicalBundle, - sample_policy: dict, - evidence_all_met: list[EvidenceItem], - ) -> None: - """Should include field mappings in response.""" - with patch( - "src.reasoning.form_generator._generate_clinical_summary", - new_callable=AsyncMock, - return_value="Clinical summary text", - ): - result = await generate_form_data( - sample_bundle, evidence_all_met, sample_policy - ) + result = await generate_form_data(bundle, evidence, policy) - assert result.field_mappings["PatientFullName"] == "John Doe" - assert result.field_mappings["DateOfBirth"] == "1980-05-15" - assert result.field_mappings["MemberID"] == "MEM123456" - assert result.field_mappings["PrimaryDiagnosis"] == "M54.5" - assert result.field_mappings["ProcedureCode"] == "72148" - assert result.field_mappings["ClinicalNotes"] == "Clinical summary text" + assert result.procedure_code == "72148" diff --git a/dashboard-emtjzpqk-20260126042446.txt b/dashboard-emtjzpqk-20260126042446.txt new file mode 100644 index 0000000..a3f8d12 --- /dev/null +++ b/dashboard-emtjzpqk-20260126042446.txt @@ -0,0 +1,36 @@ +2026-01-27T00:24:22.4112231Z Waiting for resource 'gateway' to enter the 'Running' state. +2026-01-27T00:24:32.2657776Z Waiting for resource ready to execute for 'gateway'. +2026-01-27T00:24:32.2658542Z Finished waiting for resource 'gateway'. +2026-01-27T00:24:32.2850000Z [1/2] STEP 1/6: FROM node:22-alpine AS builder +2026-01-27T00:24:32.2980000Z [1/2] STEP 2/6: WORKDIR /app +2026-01-27T00:24:32.3120000Z --> Using cache 67e7ca38d42169c174db16b2e751499cc11a6bb52411eae4690e381644c0d8a6 +2026-01-27T00:24:32.3120000Z --> 67e7ca38d421 +2026-01-27T00:24:32.3150000Z [1/2] STEP 3/6: COPY package.json package-lock.json* ./ +2026-01-27T00:24:32.3770000Z --> Using cache 280d08c8594cd2be2626a253b361376a4d2f1f3a71ed8b03ccd8afeede76fbd7 +2026-01-27T00:24:32.3770000Z --> 280d08c8594c +2026-01-27T00:24:32.3790000Z [1/2] STEP 4/6: RUN npm ci +2026-01-27T00:24:32.8570000Z npm error code EUSAGE +2026-01-27T00:24:32.8570000Z npm error +2026-01-27T00:24:32.8570000Z npm error The `npm ci` command can only install with an existing package-lock.json or +2026-01-27T00:24:32.8570000Z npm error npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or +2026-01-27T00:24:32.8570000Z npm error later to generate a package-lock.json file, then try again. +2026-01-27T00:24:32.8570000Z npm error +2026-01-27T00:24:32.8570000Z npm error Clean install a project +2026-01-27T00:24:32.8570000Z npm error +2026-01-27T00:24:32.8570000Z npm error Usage: +2026-01-27T00:24:32.8570000Z npm error npm ci +2026-01-27T00:24:32.8570000Z npm error +2026-01-27T00:24:32.8570000Z npm error Options: +2026-01-27T00:24:32.8570000Z npm error [--install-strategy ] [--legacy-bundling] +2026-01-27T00:24:32.8570000Z npm error [--global-style] [--omit [--omit ...]] +2026-01-27T00:24:32.8570000Z npm error [--include [--include ...]] +2026-01-27T00:24:32.8570000Z npm error [--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] [--no-audit] +2026-01-27T00:24:32.8570000Z npm error [--no-bin-links] [--no-fund] [--dry-run] +2026-01-27T00:24:32.8570000Z npm error [-w|--workspace [-w|--workspace ...]] +2026-01-27T00:24:32.8570000Z npm error [-ws|--workspaces] [--include-workspace-root] [--install-links] +2026-01-27T00:24:32.8570000Z npm error +2026-01-27T00:24:32.8570000Z npm error aliases: clean-install, ic, install-clean, isntall-clean +2026-01-27T00:24:32.8570000Z npm error +2026-01-27T00:24:32.8570000Z npm error Run "npm help ci" for more info +2026-01-27T00:24:32.8570000Z npm error A complete log of this run can be found in: /root/.npm/_logs/2026-01-27T00_24_32_620Z-debug-0.log +2026-01-27T00:24:35.4240000Z Error: building at STEP "RUN npm ci": while running runtime: exit status 1 diff --git a/docs/designs/2026-01-26-gateway-fhir-refactor.md b/docs/designs/2026-01-26-gateway-fhir-refactor.md new file mode 100644 index 0000000..d24bcb6 --- /dev/null +++ b/docs/designs/2026-01-26-gateway-fhir-refactor.md @@ -0,0 +1,704 @@ +# Gateway.API FHIR Infrastructure Refactor + +**Date:** 2026-01-26 +**Status:** Draft +**Reference:** aegis-api patterns from ares-elite-platform + +## Overview + +Refactor Gateway.API to adopt proven patterns from aegis-api, establishing a stable foundation for the prior authorization demo. This includes standardized error handling, proper FHIR serialization, centralized HTTP client management with resilience, and improved file organization. + +## Goals + +1. **Standardize error handling** using `Result` pattern across all services +2. **Proper FHIR serialization** using `Hl7.Fhir.Serialization` library +3. **Centralized HTTP client** with `IHttpClientProvider` and resilience policies +4. **Strongly-typed configuration** via Options pattern +5. **Single public type per file** compliance with C# coding standards + +## Non-Goals + +- Multi-project architecture (Core/Infrastructure separation) +- On-Behalf-Of (OBO) authentication flow (client credentials only) +- Full BaseFhirService/BaseFhirRepository hierarchy (keep current simpler structure) +- CDS Hooks refactoring (out of scope) + +--- + +## Architecture + +### Directory Structure (After) + +``` +Gateway.API/ +├── Abstractions/ +│ ├── Result.cs # Result generic type +│ ├── Error.cs # Error record with Code, Message, Type +│ ├── ErrorType.cs # Enum mapping to HTTP status codes +│ └── ErrorFactory.cs # Static factory for common errors +├── Configuration/ +│ ├── EpicFhirOptions.cs # FHIR endpoint configuration +│ ├── IntelligenceOptions.cs # Intelligence service config +│ └── ResiliencyOptions.cs # Retry, timeout, circuit breaker settings +├── Contracts/ +│ ├── Fhir/ +│ │ ├── IFhirContext.cs # (existing, unchanged) +│ │ ├── IFhirRepository.cs # (existing, unchanged) +│ │ └── IFhirSerializer.cs # NEW: serialization abstraction +│ ├── Http/ +│ │ └── IHttpClientProvider.cs # NEW: authenticated client provider +│ ├── IEpicFhirClient.cs # UPDATE: return Result +│ ├── IFhirDataAggregator.cs # UPDATE: return Result +│ ├── IIntelligenceClient.cs # UPDATE: return Result +│ └── IEpicUploader.cs # UPDATE: return Result +├── Errors/ +│ └── FhirErrors.cs # FHIR-specific error definitions +├── Services/ +│ ├── Fhir/ +│ │ ├── EpicFhirContext.cs # UPDATE: use IFhirSerializer +│ │ ├── FhirSerializer.cs # NEW: Hl7.Fhir.Serialization wrapper +│ │ └── ...repositories # (existing, unchanged) +│ ├── Http/ +│ │ └── HttpClientProvider.cs # NEW: token acquisition + client creation +│ ├── EpicFhirClient.cs # UPDATE: use Result, IHttpClientProvider +│ ├── FhirDataAggregator.cs # UPDATE: use Result +│ ├── IntelligenceClient.cs # UPDATE: use Result +│ └── EpicUploader.cs # UPDATE: use Result, IHttpClientProvider +├── Extensions/ +│ └── ServiceCollectionExtensions.cs # DI registration helpers +└── Program.cs # UPDATE: use extensions for clean registration +``` + +--- + +## Component Designs + +### 1. Abstractions Layer + +#### Result.cs + +Lift verbatim from aegis-api with minor adjustments for our namespace. + +```csharp +namespace Gateway.API.Abstractions; + +/// +/// Represents the outcome of an operation that can succeed or fail. +/// +/// The type of the success value. +public readonly record struct Result +{ + public T? Value { get; } + public Error? Error { get; } + public bool IsSuccess => Error is null; + public bool IsFailure => !IsSuccess; + + private Result(T value) { Value = value; Error = null; } + private Result(Error error) { Value = default; Error = error; } + + public static Result Success(T value) => new(value); + public static Result Failure(Error error) => new(error); + + public TResult Match( + Func onSuccess, + Func onFailure) + => IsSuccess ? onSuccess(Value!) : onFailure(Error!); + + public Result Map(Func mapper) + => IsSuccess ? Result.Success(mapper(Value!)) : Result.Failure(Error!); + + public static implicit operator Result(T value) => Success(value); + public static implicit operator Result(Error error) => Failure(error); +} +``` + +#### Error.cs + +```csharp +namespace Gateway.API.Abstractions; + +/// +/// Represents an error from an operation. +/// +/// Machine-readable error code. +/// Human-readable error message. +/// Error classification for HTTP status mapping. +public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Unexpected) +{ + /// Optional inner exception for logging. + public Exception? Inner { get; init; } +} +``` + +#### ErrorType.cs + +```csharp +namespace Gateway.API.Abstractions; + +/// +/// Error classification mapped to HTTP status codes. +/// +public enum ErrorType +{ + None = 0, + NotFound = 404, + Validation = 400, + Conflict = 409, + Unauthorized = 401, + Forbidden = 403, + Infrastructure = 503, + Unexpected = 500 +} +``` + +#### ErrorFactory.cs + +```csharp +namespace Gateway.API.Abstractions; + +/// +/// Factory methods for common error types. +/// +public static class ErrorFactory +{ + public static Error NotFound(string resource, string id) + => new($"{resource}.NotFound", $"{resource}/{id} not found", ErrorType.NotFound); + + public static Error Validation(string message) + => new("Validation.Failed", message, ErrorType.Validation); + + public static Error Unauthorized(string message = "Authentication required") + => new("Auth.Unauthorized", message, ErrorType.Unauthorized); + + public static Error Infrastructure(string message, Exception? inner = null) + => new("Infrastructure.Error", message, ErrorType.Infrastructure) { Inner = inner }; + + public static Error Unexpected(string message, Exception? inner = null) + => new("Unexpected.Error", message, ErrorType.Unexpected) { Inner = inner }; +} +``` + +--- + +### 2. Configuration Layer + +#### EpicFhirOptions.cs + +```csharp +namespace Gateway.API.Configuration; + +/// +/// Configuration for Epic FHIR API connectivity. +/// +public sealed class EpicFhirOptions +{ + public const string SectionName = "Epic"; + + /// Base URL for Epic FHIR R4 API. + public required string FhirBaseUrl { get; init; } + + /// OAuth client ID for Epic. + public required string ClientId { get; init; } + + /// OAuth client secret (from user-secrets in dev). + public string? ClientSecret { get; init; } + + /// Token endpoint for client credentials flow. + public string? TokenEndpoint { get; init; } +} +``` + +#### IntelligenceOptions.cs + +```csharp +namespace Gateway.API.Configuration; + +/// +/// Configuration for Intelligence service connectivity. +/// +public sealed class IntelligenceOptions +{ + public const string SectionName = "Intelligence"; + + /// Base URL for Intelligence API. + public required string BaseUrl { get; init; } + + /// Request timeout in seconds. + public int TimeoutSeconds { get; init; } = 30; +} +``` + +#### ResiliencyOptions.cs + +```csharp +namespace Gateway.API.Configuration; + +/// +/// Configuration for HTTP resilience policies. +/// +public sealed class ResiliencyOptions +{ + public const string SectionName = "Resilience"; + + /// Maximum retry attempts. + public int MaxRetryAttempts { get; init; } = 3; + + /// Base delay between retries in seconds. + public double RetryDelaySeconds { get; init; } = 1.0; + + /// Request timeout in seconds. + public int TimeoutSeconds { get; init; } = 10; + + /// Circuit breaker failure threshold. + public int CircuitBreakerThreshold { get; init; } = 5; + + /// Circuit breaker break duration in seconds. + public int CircuitBreakerDurationSeconds { get; init; } = 30; +} +``` + +--- + +### 3. FHIR Serialization + +#### IFhirSerializer.cs + +```csharp +namespace Gateway.API.Contracts.Fhir; + +using Hl7.Fhir.Model; + +/// +/// Abstraction for FHIR JSON serialization. +/// +public interface IFhirSerializer +{ + /// Serialize a FHIR resource to JSON string. + string Serialize(T resource) where T : Resource; + + /// Deserialize JSON string to FHIR resource. + T? Deserialize(string json) where T : Resource; + + /// Deserialize JSON to a Bundle resource. + Bundle? DeserializeBundle(string json); +} +``` + +#### FhirSerializer.cs + +```csharp +namespace Gateway.API.Services.Fhir; + +using Hl7.Fhir.Model; +using Hl7.Fhir.Serialization; +using Gateway.API.Contracts.Fhir; + +/// +/// FHIR JSON serialization using Hl7.Fhir library. +/// +public sealed class FhirSerializer : IFhirSerializer +{ + private static readonly FhirJsonSerializer s_serializer = new(); + private static readonly FhirJsonParser s_parser = new(); + private readonly ILogger _logger; + + public FhirSerializer(ILogger logger) + { + _logger = logger; + } + + public string Serialize(T resource) where T : Resource + { + ArgumentNullException.ThrowIfNull(resource); + try + { + return s_serializer.SerializeToString(resource); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to serialize {ResourceType}", typeof(T).Name); + throw; + } + } + + public T? Deserialize(string json) where T : Resource + { + if (string.IsNullOrWhiteSpace(json)) return null; + try + { + return s_parser.Parse(json); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize {ResourceType}", typeof(T).Name); + return null; + } + } + + public Bundle? DeserializeBundle(string json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + try + { + return s_parser.Parse(json); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize Bundle"); + return null; + } + } +} +``` + +--- + +### 4. HTTP Client Provider + +#### IHttpClientProvider.cs + +```csharp +namespace Gateway.API.Contracts.Http; + +/// +/// Provides authenticated HTTP clients for downstream services. +/// +public interface IHttpClientProvider +{ + /// + /// Gets an HTTP client authenticated via client credentials flow. + /// + /// Named HttpClient to retrieve. + /// Cancellation token. + /// Authenticated HttpClient or null if auth fails. + Task GetAuthenticatedClientAsync( + string clientName, + CancellationToken cancellationToken = default); +} +``` + +#### HttpClientProvider.cs + +```csharp +namespace Gateway.API.Services.Http; + +using System.Net.Http.Headers; +using Gateway.API.Contracts.Http; +using Gateway.API.Configuration; +using Microsoft.Extensions.Options; + +/// +/// Provides authenticated HTTP clients using client credentials flow. +/// +public sealed class HttpClientProvider : IHttpClientProvider +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly EpicFhirOptions _epicOptions; + private readonly ILogger _logger; + + // Simple token cache (production would use IMemoryCache with expiry) + private string? _cachedToken; + private DateTime _tokenExpiry = DateTime.MinValue; + + public HttpClientProvider( + IHttpClientFactory httpClientFactory, + IOptions epicOptions, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _epicOptions = epicOptions.Value; + _logger = logger; + } + + public async Task GetAuthenticatedClientAsync( + string clientName, + CancellationToken cancellationToken = default) + { + var client = _httpClientFactory.CreateClient(clientName); + + // For demo: if no token endpoint configured, return unauthenticated client + // Epic sandbox doesn't require real OAuth for some endpoints + if (string.IsNullOrEmpty(_epicOptions.TokenEndpoint)) + { + _logger.LogDebug("No token endpoint configured, returning unauthenticated client"); + return client; + } + + var token = await GetOrRefreshTokenAsync(cancellationToken); + if (token is null) + { + _logger.LogError("Failed to acquire access token"); + return null; + } + + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token); + + return client; + } + + private async Task GetOrRefreshTokenAsync(CancellationToken ct) + { + if (_cachedToken is not null && DateTime.UtcNow < _tokenExpiry) + { + return _cachedToken; + } + + try + { + using var tokenClient = _httpClientFactory.CreateClient(); + var content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = _epicOptions.ClientId, + ["client_secret"] = _epicOptions.ClientSecret ?? "" + }); + + var response = await tokenClient.PostAsync(_epicOptions.TokenEndpoint, content, ct); + response.EnsureSuccessStatusCode(); + + var tokenResponse = await response.Content.ReadFromJsonAsync(ct); + if (tokenResponse is null) return null; + + _cachedToken = tokenResponse.AccessToken; + _tokenExpiry = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60); // 1 min buffer + + return _cachedToken; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to acquire token from {Endpoint}", _epicOptions.TokenEndpoint); + return null; + } + } + + private sealed record TokenResponse( + [property: System.Text.Json.Serialization.JsonPropertyName("access_token")] + string AccessToken, + [property: System.Text.Json.Serialization.JsonPropertyName("expires_in")] + int ExpiresIn); +} +``` + +--- + +### 5. FHIR-Specific Errors + +#### FhirErrors.cs + +```csharp +namespace Gateway.API.Errors; + +using Gateway.API.Abstractions; + +/// +/// Predefined errors for FHIR operations. +/// +public static class FhirErrors +{ + public static readonly Error ServiceUnavailable = + new("Fhir.ServiceUnavailable", "FHIR service is unavailable", ErrorType.Infrastructure); + + public static readonly Error Timeout = + new("Fhir.Timeout", "FHIR request timed out", ErrorType.Infrastructure); + + public static readonly Error AuthenticationFailed = + new("Fhir.AuthFailed", "Failed to authenticate with FHIR server", ErrorType.Unauthorized); + + public static Error NotFound(string resourceType, string id) => + ErrorFactory.NotFound(resourceType, id); + + public static Error InvalidResponse(string details) => + new("Fhir.InvalidResponse", $"Invalid FHIR response: {details}", ErrorType.Infrastructure); + + public static Error NetworkError(string message, Exception? inner = null) => + new("Fhir.NetworkError", message, ErrorType.Infrastructure) { Inner = inner }; +} +``` + +--- + +### 6. Service Updates + +#### EpicFhirContext.cs Changes + +**Before:** +```csharp +var resource = await response.Content.ReadFromJsonAsync(cancellationToken: ct); +``` + +**After:** +```csharp +var json = await response.Content.ReadAsStringAsync(ct); +var resource = _fhirSerializer.Deserialize(json); +``` + +**Key Changes:** +- Inject `IFhirSerializer` instead of using `System.Text.Json` +- Update `ExtractResourcesFromBundle` to use `IFhirSerializer.DeserializeBundle` + +#### EpicFhirClient.cs Changes + +**Before:** +```csharp +public async Task GetPatientAsync(string patientId, string accessToken, ...) +``` + +**After:** +```csharp +public async Task> GetPatientAsync(string patientId, CancellationToken ct = default) +``` + +**Key Changes:** +- Return `Result` instead of nullable +- Use `IHttpClientProvider` instead of direct token parameter +- Use `IFhirSerializer` for parsing FHIR resources +- Map FHIR `Patient` → `PatientInfo` after proper deserialization + +#### Interface Updates + +All service interfaces updated to return `Result`: + +```csharp +// IEpicFhirClient +Task> GetPatientAsync(string patientId, CancellationToken ct = default); +Task>> SearchConditionsAsync(string patientId, CancellationToken ct = default); +// ... etc + +// IIntelligenceClient +Task> AnalyzeAsync(ClinicalBundle bundle, CancellationToken ct = default); + +// IEpicUploader +Task> UploadDocumentAsync(string patientId, byte[] pdfContent, CancellationToken ct = default); + +// IFhirDataAggregator +Task> AggregateClinicalDataAsync(string patientId, CancellationToken ct = default); +``` + +--- + +### 7. Resilience Configuration + +Using `Microsoft.Extensions.Http.Resilience` for standard resilience pipeline: + +```csharp +// In ServiceCollectionExtensions.cs +public static IServiceCollection AddEpicFhirClient( + this IServiceCollection services, + IConfiguration configuration) +{ + services.Configure(configuration.GetSection(EpicFhirOptions.SectionName)); + services.Configure(configuration.GetSection(ResiliencyOptions.SectionName)); + + services.AddHttpClient("EpicFhir", (sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.FhirBaseUrl); + client.DefaultRequestHeaders.Add("Accept", "application/fhir+json"); + }) + .AddStandardResilienceHandler(options => + { + // Uses sensible defaults: retry, circuit breaker, timeout + // Can customize via ResiliencyOptions if needed + }); + + return services; +} +``` + +--- + +## Migration Plan + +### Phase 1: Abstractions & Configuration +1. Create `Abstractions/` directory with Result, Error, ErrorType, ErrorFactory +2. Create `Configuration/` directory with Options classes +3. Create `Errors/FhirErrors.cs` +4. Delete old `Contracts/Result.cs` + +### Phase 2: FHIR Serialization +1. Create `Contracts/Fhir/IFhirSerializer.cs` +2. Create `Services/Fhir/FhirSerializer.cs` +3. Update `EpicFhirContext` to use `IFhirSerializer` +4. Register `IFhirSerializer` in DI + +### Phase 3: HTTP Client Provider +1. Create `Contracts/Http/IHttpClientProvider.cs` +2. Create `Services/Http/HttpClientProvider.cs` +3. Create `Extensions/ServiceCollectionExtensions.cs` +4. Update `Program.cs` to use extension methods with resilience + +### Phase 4: Service Migration +1. Update `IEpicFhirClient` interface (Result returns) +2. Update `EpicFhirClient` implementation +3. Update `IIntelligenceClient` and implementation +4. Update `IEpicUploader` and implementation +5. Update `IFhirDataAggregator` and implementation +6. Update endpoint handlers to use Result.Match() + +### Phase 5: Cleanup +1. Remove unused code from old Result.cs +2. Update any remaining exception-throwing code +3. Verify all services return Result + +--- + +## Testing Strategy + +1. **Unit tests** for FhirSerializer with sample FHIR JSON +2. **Unit tests** for Result Match/Map operations +3. **Integration tests** for HttpClientProvider token acquisition (mocked) +4. **Existing endpoint tests** updated for Result responses + +--- + +## Files to Create + +| File | Description | +|------|-------------| +| `Abstractions/Result.cs` | Generic result type | +| `Abstractions/Error.cs` | Error record | +| `Abstractions/ErrorType.cs` | Error classification enum | +| `Abstractions/ErrorFactory.cs` | Common error factories | +| `Configuration/EpicFhirOptions.cs` | Epic FHIR config | +| `Configuration/IntelligenceOptions.cs` | Intelligence service config | +| `Configuration/ResiliencyOptions.cs` | Resilience settings | +| `Contracts/Fhir/IFhirSerializer.cs` | Serialization interface | +| `Contracts/Http/IHttpClientProvider.cs` | HTTP provider interface | +| `Services/Fhir/FhirSerializer.cs` | Hl7.Fhir wrapper | +| `Services/Http/HttpClientProvider.cs` | Token + client provider | +| `Errors/FhirErrors.cs` | FHIR error definitions | +| `Extensions/ServiceCollectionExtensions.cs` | DI helpers | + +## Files to Modify + +| File | Changes | +|------|---------| +| `Contracts/IEpicFhirClient.cs` | Return Result | +| `Contracts/IIntelligenceClient.cs` | Return Result | +| `Contracts/IEpicUploader.cs` | Return Result | +| `Contracts/IFhirDataAggregator.cs` | Return Result | +| `Services/Fhir/EpicFhirContext.cs` | Use IFhirSerializer | +| `Services/EpicFhirClient.cs` | Full rewrite with Result | +| `Services/IntelligenceClient.cs` | Return Result | +| `Services/EpicUploader.cs` | Return Result, use provider | +| `Services/FhirDataAggregator.cs` | Return Result | +| `Program.cs` | Use extension methods | +| `Endpoints/*.cs` | Handle Result responses | + +## Files to Delete + +| File | Reason | +|------|--------| +| `Contracts/Result.cs` | Replaced by Abstractions/Result.cs + Error.cs | + +--- + +## Success Criteria + +1. All services return `Result` (no null returns, no thrown exceptions for expected errors) +2. FHIR JSON parsed via `Hl7.Fhir.Serialization` +3. HTTP clients created via `IHttpClientFactory` with resilience policies +4. Single public type per file in all new/modified files +5. All existing functionality preserved +6. Demo app starts and completes PA workflow successfully diff --git a/docs/plans/2026-01-26-gateway-fhir-refactor.md b/docs/plans/2026-01-26-gateway-fhir-refactor.md new file mode 100644 index 0000000..eec654c --- /dev/null +++ b/docs/plans/2026-01-26-gateway-fhir-refactor.md @@ -0,0 +1,486 @@ +# Implementation Plan: Gateway.API FHIR Infrastructure Refactor + +**Design:** [2026-01-26-gateway-fhir-refactor.md](../designs/2026-01-26-gateway-fhir-refactor.md) +**Date:** 2026-01-26 + +## Overview + +TDD implementation plan for refactoring Gateway.API to use aegis-api patterns: Result error handling, Hl7.Fhir serialization, IHttpClientProvider with resilience. + +## Task Groups + +### Group A: Abstractions (Sequential - Foundation) +Tasks 001-004 must complete before other groups can start. + +### Group B: Configuration (Parallel-Safe) +Tasks 005-007 can run in parallel after Group A. + +### Group C: FHIR Serialization (Parallel-Safe) +Tasks 008-009 can run in parallel after Group A. + +### Group D: HTTP Infrastructure (Sequential) +Tasks 010-012 sequential, after Group A. + +### Group E: Service Migration (Sequential) +Tasks 013-018 sequential, after Groups B, C, D complete. + +### Group F: Integration & Cleanup (Sequential) +Tasks 019-020 after Group E. + +--- + +## Tasks + +### Task 001: Result Type +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** A (Foundation) + +1. [RED] Write test: `Result_Success_ContainsValue` + - File: `Gateway.API.Tests/Abstractions/ResultTests.cs` + - Test that `Result.Success(value)` sets `IsSuccess=true` and `Value=value` + +2. [RED] Write test: `Result_Failure_ContainsError` + - Test that `Result.Failure(error)` sets `IsFailure=true` and `Error=error` + +3. [RED] Write test: `Result_Match_ExecutesCorrectBranch` + - Test that `Match()` executes `onSuccess` for success and `onFailure` for failure + +4. [RED] Write test: `Result_Map_TransformsSuccessValue` + - Test that `Map()` transforms success value and propagates failure + +5. [GREEN] Implement `Result` + - File: `Gateway.API/Abstractions/Result.cs` + +6. [REFACTOR] Ensure single public type per file + +**Dependencies:** None +**Parallelizable:** No (foundation) + +--- + +### Task 002: Error Record +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** A (Foundation) + +1. [RED] Write test: `Error_Constructor_SetsAllProperties` + - File: `Gateway.API.Tests/Abstractions/ErrorTests.cs` + - Test Error record with Code, Message, Type, Inner + +2. [GREEN] Implement `Error` and `ErrorType` + - File: `Gateway.API/Abstractions/Error.cs` + - File: `Gateway.API/Abstractions/ErrorType.cs` + +3. [REFACTOR] Verify enum values match HTTP status codes + +**Dependencies:** None +**Parallelizable:** No (foundation, but can parallel with 001) + +--- + +### Task 003: ErrorFactory +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** A (Foundation) + +1. [RED] Write test: `ErrorFactory_NotFound_ReturnsCorrectError` + - File: `Gateway.API.Tests/Abstractions/ErrorFactoryTests.cs` + - Test `ErrorFactory.NotFound("Patient", "123")` returns proper error + +2. [RED] Write test: `ErrorFactory_AllMethods_ReturnCorrectErrorType` + - Test Validation, Unauthorized, Infrastructure, Unexpected + +3. [GREEN] Implement `ErrorFactory` + - File: `Gateway.API/Abstractions/ErrorFactory.cs` + +**Dependencies:** 002 +**Parallelizable:** No + +--- + +### Task 004: FhirErrors +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** A (Foundation) + +1. [RED] Write test: `FhirErrors_StaticErrors_HaveCorrectCodes` + - File: `Gateway.API.Tests/Errors/FhirErrorsTests.cs` + - Test ServiceUnavailable, Timeout, AuthenticationFailed have correct codes + +2. [RED] Write test: `FhirErrors_FactoryMethods_ReturnCorrectErrors` + - Test NotFound, InvalidResponse, NetworkError factories + +3. [GREEN] Implement `FhirErrors` + - File: `Gateway.API/Errors/FhirErrors.cs` + +**Dependencies:** 003 +**Parallelizable:** No + +--- + +### Task 005: EpicFhirOptions +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** B (Configuration) + +1. [RED] Write test: `EpicFhirOptions_Binding_LoadsFromConfiguration` + - File: `Gateway.API.Tests/Configuration/EpicFhirOptionsTests.cs` + - Test IOptions binds from IConfiguration + +2. [GREEN] Implement `EpicFhirOptions` + - File: `Gateway.API/Configuration/EpicFhirOptions.cs` + +**Dependencies:** 001-004 (Group A complete) +**Parallelizable:** Yes (with 006, 007) + +--- + +### Task 006: IntelligenceOptions +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** B (Configuration) + +1. [RED] Write test: `IntelligenceOptions_Binding_LoadsFromConfiguration` + - File: `Gateway.API.Tests/Configuration/IntelligenceOptionsTests.cs` + - Test default TimeoutSeconds = 30 + +2. [GREEN] Implement `IntelligenceOptions` + - File: `Gateway.API/Configuration/IntelligenceOptions.cs` + +**Dependencies:** 001-004 (Group A complete) +**Parallelizable:** Yes (with 005, 007) + +--- + +### Task 007: ResiliencyOptions +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** B (Configuration) + +1. [RED] Write test: `ResiliencyOptions_Defaults_HaveReasonableValues` + - File: `Gateway.API.Tests/Configuration/ResiliencyOptionsTests.cs` + - Test default MaxRetryAttempts=3, TimeoutSeconds=10, etc. + +2. [GREEN] Implement `ResiliencyOptions` + - File: `Gateway.API/Configuration/ResiliencyOptions.cs` + +**Dependencies:** 001-004 (Group A complete) +**Parallelizable:** Yes (with 005, 006) + +--- + +### Task 008: IFhirSerializer Interface +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** C (FHIR Serialization) + +1. [RED] Write test: `FhirSerializer_Serialize_ProducesValidJson` + - File: `Gateway.API.Tests/Services/Fhir/FhirSerializerTests.cs` + - Create Patient resource, serialize, verify JSON contains resourceType + +2. [RED] Write test: `FhirSerializer_Deserialize_ParsesValidResource` + - Test deserializing a valid Patient JSON string + +3. [RED] Write test: `FhirSerializer_DeserializeBundle_ExtractsEntries` + - Test deserializing a Bundle with entries + +4. [RED] Write test: `FhirSerializer_Deserialize_InvalidJson_ReturnsNull` + - Test graceful handling of invalid JSON + +5. [GREEN] Implement interface and implementation + - File: `Gateway.API/Contracts/Fhir/IFhirSerializer.cs` + - File: `Gateway.API/Services/Fhir/FhirSerializer.cs` + +**Dependencies:** 001-004 (Group A complete) +**Parallelizable:** Yes (with Group B) + +--- + +### Task 009: Update EpicFhirContext to Use IFhirSerializer +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** C (FHIR Serialization) + +1. [RED] Write test: `EpicFhirContext_ReadAsync_UsesFhirSerializer` + - File: `Gateway.API.Tests/Services/Fhir/EpicFhirContextTests.cs` + - Mock IFhirSerializer, verify Deserialize called + +2. [RED] Write test: `EpicFhirContext_SearchAsync_UsesBundleDeserialization` + - Test that SearchAsync uses DeserializeBundle + +3. [GREEN] Update `EpicFhirContext` constructor and methods + - File: `Gateway.API/Services/Fhir/EpicFhirContext.cs` + - Inject IFhirSerializer, replace System.Text.Json calls + +4. [REFACTOR] Remove old JsonSerializer/JsonElement usage + +**Dependencies:** 008 +**Parallelizable:** No (depends on 008) + +--- + +### Task 010: IHttpClientProvider Interface +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** D (HTTP Infrastructure) + +1. [RED] Write test: `HttpClientProvider_NoTokenEndpoint_ReturnsUnauthenticatedClient` + - File: `Gateway.API.Tests/Services/Http/HttpClientProviderTests.cs` + - Test that missing TokenEndpoint returns client without Authorization header + +2. [GREEN] Implement interface + - File: `Gateway.API/Contracts/Http/IHttpClientProvider.cs` + +**Dependencies:** 005 (EpicFhirOptions) +**Parallelizable:** No + +--- + +### Task 011: HttpClientProvider Implementation +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** D (HTTP Infrastructure) + +1. [RED] Write test: `HttpClientProvider_WithTokenEndpoint_AcquiresToken` + - Mock token endpoint response, verify Authorization header set + +2. [RED] Write test: `HttpClientProvider_CachesToken_UntilExpiry` + - Test that subsequent calls use cached token + +3. [RED] Write test: `HttpClientProvider_TokenExpired_RefreshesToken` + - Test token refresh after expiry + +4. [RED] Write test: `HttpClientProvider_TokenAcquisitionFails_ReturnsNull` + - Test graceful failure handling + +5. [GREEN] Implement `HttpClientProvider` + - File: `Gateway.API/Services/Http/HttpClientProvider.cs` + +**Dependencies:** 010 +**Parallelizable:** No + +--- + +### Task 012: ServiceCollectionExtensions with Resilience +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** D (HTTP Infrastructure) + +1. [RED] Write test: `AddEpicFhirClient_RegistersHttpClientWithResilience` + - File: `Gateway.API.Tests/Extensions/ServiceCollectionExtensionsTests.cs` + - Verify named HttpClient "EpicFhir" is registered + +2. [RED] Write test: `AddIntelligenceClient_RegistersHttpClientWithResilience` + - Verify named HttpClient "Intelligence" is registered + +3. [GREEN] Implement `ServiceCollectionExtensions` + - File: `Gateway.API/Extensions/ServiceCollectionExtensions.cs` + - Use AddStandardResilienceHandler from Microsoft.Extensions.Http.Resilience + +**Dependencies:** 005, 006, 007, 011 +**Parallelizable:** No + +--- + +### Task 013: Update IEpicFhirClient Interface +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** E (Service Migration) + +1. [RED] Update existing tests to expect `Result` returns + - File: `Gateway.API.Tests/Services/EpicFhirClientTests.cs` (create if needed) + - Test GetPatientAsync returns `Result` + - Test failure cases return `Result.Failure(error)` + +2. [GREEN] Update interface + - File: `Gateway.API/Contracts/IEpicFhirClient.cs` + - Change all returns to `Result`, remove accessToken parameter + +**Dependencies:** 004, 009, 011 +**Parallelizable:** No + +--- + +### Task 014: Update EpicFhirClient Implementation +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** E (Service Migration) + +1. [RED] Write test: `EpicFhirClient_GetPatientAsync_Success_ReturnsPatientInfo` + - Mock HttpClient response with valid Patient JSON + - Verify Result.IsSuccess and Value populated + +2. [RED] Write test: `EpicFhirClient_GetPatientAsync_NotFound_ReturnsFailure` + - Mock 404 response, verify Result.IsFailure with NotFound error + +3. [RED] Write test: `EpicFhirClient_GetPatientAsync_NetworkError_ReturnsFailure` + - Mock HttpRequestException, verify Infrastructure error + +4. [RED] Write tests for SearchConditionsAsync, SearchObservationsAsync, etc. + - Similar pattern for each method + +5. [GREEN] Rewrite `EpicFhirClient` + - File: `Gateway.API/Services/EpicFhirClient.cs` + - Inject IHttpClientProvider, IFhirSerializer + - Return Result from all methods + - Use FHIR model types for parsing, map to info DTOs + +**Dependencies:** 013 +**Parallelizable:** No + +--- + +### Task 015: Update IFhirDataAggregator and Implementation +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** E (Service Migration) + +1. [RED] Write test: `FhirDataAggregator_AllCallsSucceed_ReturnsClinicalBundle` + - File: `Gateway.API.Tests/Services/FhirDataAggregatorTests.cs` + - Mock all IEpicFhirClient methods returning success + - Verify aggregated Result + +2. [RED] Write test: `FhirDataAggregator_PatientFails_ReturnsFailure` + - Test that patient fetch failure propagates + +3. [RED] Write test: `FhirDataAggregator_PartialFailure_IncludesSuccessfulData` + - Test that conditions failure doesn't block observations + +4. [GREEN] Update interface and implementation + - File: `Gateway.API/Contracts/IFhirDataAggregator.cs` + - File: `Gateway.API/Services/FhirDataAggregator.cs` + - Return `Result` + - Remove accessToken parameter (use IHttpClientProvider) + +**Dependencies:** 014 +**Parallelizable:** No + +--- + +### Task 016: Update IIntelligenceClient and Implementation +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** E (Service Migration) + +1. [RED] Write test: `IntelligenceClient_AnalyzeAsync_Success_ReturnsPAFormData` + - File: `Gateway.API.Tests/Services/IntelligenceClientTests.cs` + - Mock successful /analyze response + +2. [RED] Write test: `IntelligenceClient_AnalyzeAsync_HttpError_ReturnsFailure` + - Mock 500 response, verify Infrastructure error + +3. [RED] Write test: `IntelligenceClient_AnalyzeAsync_InvalidResponse_ReturnsFailure` + - Mock malformed JSON response + +4. [GREEN] Update interface and implementation + - File: `Gateway.API/Contracts/IIntelligenceClient.cs` + - File: `Gateway.API/Services/IntelligenceClient.cs` + - Return `Result` + - Use registered HttpClient from IHttpClientFactory + +**Dependencies:** 012 +**Parallelizable:** Yes (with 015, after 012) + +--- + +### Task 017: Update IEpicUploader and Implementation +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** E (Service Migration) + +1. [RED] Write test: `EpicUploader_UploadDocumentAsync_Success_ReturnsDocumentId` + - File: `Gateway.API.Tests/Services/EpicUploaderTests.cs` + - Mock successful FHIR POST response + +2. [RED] Write test: `EpicUploader_UploadDocumentAsync_Unauthorized_ReturnsFailure` + - Mock 401 response + +3. [GREEN] Update interface and implementation + - File: `Gateway.API/Contracts/IEpicUploader.cs` + - File: `Gateway.API/Services/EpicUploader.cs` + - Return `Result` + - Use IHttpClientProvider, IFhirSerializer + +**Dependencies:** 011, 008 +**Parallelizable:** Yes (with 015, 016) + +--- + +### Task 018: Update Endpoint Handlers for Result +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** E (Service Migration) + +1. [RED] Update test: `AnalysisEndpointsTests` to use Result mocks + - File: `Gateway.API.Tests/Endpoints/AnalysisEndpointsTests.cs` + - Update mock returns to use Result + - Verify Result.Match() used for HTTP response mapping + +2. [GREEN] Update endpoint handlers + - File: `Gateway.API/Endpoints/AnalysisEndpoints.cs` + - File: `Gateway.API/Endpoints/CdsHooksEndpoints.cs` (if applicable) + - Use Result.Match() to map to HTTP responses + +**Dependencies:** 015, 016, 017 +**Parallelizable:** No + +--- + +### Task 019: Update Program.cs DI Registration +**Phase:** RED → GREEN → REFACTOR +**Parallel Group:** F (Integration) + +1. [RED] Write integration test: `Program_ServicesRegistered_CanResolveAllDependencies` + - File: `Gateway.API.Tests/Integration/DependencyInjectionTests.cs` + - Build ServiceProvider, resolve key services + +2. [GREEN] Update `Program.cs` + - File: `Gateway.API/Program.cs` + - Use ServiceCollectionExtensions + - Register IFhirSerializer, IHttpClientProvider + - Configure options from appsettings + +**Dependencies:** 012, 018 +**Parallelizable:** No + +--- + +### Task 020: Delete Old Result.cs and Final Cleanup +**Phase:** REFACTOR +**Parallel Group:** F (Integration) + +1. Delete `Gateway.API/Contracts/Result.cs` +2. Update any remaining `using Gateway.API.Contracts;` to include `Gateway.API.Abstractions` +3. Run full test suite +4. Verify build succeeds +5. Verify demo app starts and runs + +**Dependencies:** 019 +**Parallelizable:** No + +--- + +## Execution Order + +``` +Phase 1 (Foundation): + 001 → 002 → 003 → 004 + +Phase 2 (Parallel): + [005, 006, 007] (Config) + [008 → 009] (FHIR Serialization) + [010 → 011 → 012] (HTTP) + +Phase 3 (Service Migration): + 013 → 014 → 015 + ↘ + 016 ─────────→ 018 + ↗ + 017 ───────── + +Phase 4 (Integration): + 019 → 020 +``` + +## Worktree Strategy + +| Branch | Tasks | Description | +|--------|-------|-------------| +| `feature/001-abstractions` | 001-004 | Foundation types | +| `feature/005-config` | 005-007 | Configuration options | +| `feature/008-fhir-serializer` | 008-009 | FHIR serialization | +| `feature/010-http-provider` | 010-012 | HTTP infrastructure | +| `feature/013-service-migration` | 013-018 | Service updates | +| `feature/019-integration` | 019-020 | Final integration | + +## Success Criteria + +1. All 20 tasks complete with passing tests +2. Zero nullable return types in service interfaces (all use Result) +3. Zero System.Text.Json usage for FHIR resources +4. All HTTP clients use IHttpClientFactory with resilience +5. Single public type per file in all new files +6. Demo app completes full PA workflow diff --git a/orchestration/AuthScript.AppHost/AppHost.cs b/orchestration/AuthScript.AppHost/AppHost.cs index bdf1f49..f0ab3f7 100644 --- a/orchestration/AuthScript.AppHost/AppHost.cs +++ b/orchestration/AuthScript.AppHost/AppHost.cs @@ -70,9 +70,10 @@ // --------------------------------------------------------------------------- // Dashboard (React/Vite + nginx in container) // Containerized for production-like SPA serving with nginx +// Uses Dockerfile.build from monorepo root to access shared packages // --------------------------------------------------------------------------- var dashboard = builder - .AddDockerfile("dashboard", "../../apps/dashboard") + .AddDockerfile("dashboard", "../..", "apps/dashboard/Dockerfile.build") .WithHttpEndpoint(port: 3000, targetPort: 80, name: "dashboard-ui") .WaitFor(gateway) .WithExternalHttpEndpoints(); diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..ddf3b25 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# =========================================================================== +# AuthScript Setup Script +# Configures dotnet user-secrets for Aspire AppHost +# =========================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +APPHOST_PROJECT="$PROJECT_ROOT/orchestration/AuthScript.AppHost" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +info() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# --------------------------------------------------------------------------- +# Check prerequisites +# --------------------------------------------------------------------------- +if ! command -v dotnet &> /dev/null; then + error "dotnet CLI not found. Please install .NET SDK." + exit 1 +fi + +# --------------------------------------------------------------------------- +# Initialize user-secrets if needed +# --------------------------------------------------------------------------- +info "Configuring user-secrets for AuthScript.AppHost..." + +# Guard: Check if AppHost project directory exists +if [[ ! -d "$APPHOST_PROJECT" ]]; then + error "AppHost project not found at: $APPHOST_PROJECT" + exit 1 +fi + +cd "$APPHOST_PROJECT" + +# --------------------------------------------------------------------------- +# LLM Provider Selection +# Options: github (default), azure, gemini, openai +# --------------------------------------------------------------------------- +LLM_PROVIDER="${LLM_PROVIDER:-github}" + +dotnet user-secrets set "Parameters:llm-provider" "$LLM_PROVIDER" +info "Set llm-provider to '$LLM_PROVIDER'" + +# --------------------------------------------------------------------------- +# GitHub Token (primary LLM provider) +# Priority: argument > environment > gh CLI > placeholder +# --------------------------------------------------------------------------- +GITHUB_TOKEN="${1:-${GITHUB_TOKEN:-}}" + +if [[ -z "$GITHUB_TOKEN" ]] && command -v gh &> /dev/null; then + if gh auth status &> /dev/null; then + GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)" + if [[ -n "$GITHUB_TOKEN" ]]; then + info "Using GitHub token from gh CLI" + fi + fi +fi + +if [[ -n "$GITHUB_TOKEN" ]]; then + dotnet user-secrets set "Parameters:github-token" "$GITHUB_TOKEN" + info "Set github-token" +else + dotnet user-secrets set "Parameters:github-token" "not-configured" + warn "No GitHub token found - set placeholder (configure later with gh auth login)" +fi + +# --------------------------------------------------------------------------- +# Azure OpenAI (optional - use placeholder if not configured) +# --------------------------------------------------------------------------- +AZURE_KEY="${AZURE_OPENAI_API_KEY:-not-configured}" +AZURE_ENDPOINT="${AZURE_OPENAI_ENDPOINT:-not-configured}" + +dotnet user-secrets set "Parameters:azure-openai-key" "$AZURE_KEY" +dotnet user-secrets set "Parameters:azure-openai-endpoint" "$AZURE_ENDPOINT" + +if [[ "$AZURE_KEY" != "not-configured" ]]; then + info "Set azure-openai-key from environment" +else + info "Set azure-openai-key placeholder (optional)" +fi + +# --------------------------------------------------------------------------- +# Google Gemini (optional - use placeholder if not configured) +# --------------------------------------------------------------------------- +GOOGLE_KEY="${GOOGLE_API_KEY:-not-configured}" + +dotnet user-secrets set "Parameters:google-api-key" "$GOOGLE_KEY" + +if [[ "$GOOGLE_KEY" != "not-configured" ]]; then + info "Set google-api-key from environment" +else + info "Set google-api-key placeholder (optional)" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +info "Setup complete! Current configuration:" +while read -r line; do + # Mask secret values in output (but show provider) + key=$(echo "$line" | cut -d'=' -f1) + value=$(echo "$line" | cut -d'=' -f2- | xargs) + if [[ "$key" == *"llm-provider"* ]]; then + echo " $key = $value" + elif [[ -n "$value" && "$value" != "not-configured" ]]; then + echo " $key = ********" + else + echo " $key = (not configured)" + fi +done < <(dotnet user-secrets list | grep -E "llm-provider|github-token|azure-openai|google-api" || true) + +echo "" +info "To switch LLM providers:" +echo "" +echo " # GitHub Models (default - free with GitHub account)" +echo " LLM_PROVIDER=github ./scripts/setup.sh" +echo "" +echo " # Azure OpenAI" +echo " LLM_PROVIDER=azure AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=https://... ./scripts/setup.sh" +echo "" +echo " # Google Gemini" +echo " LLM_PROVIDER=gemini GOOGLE_API_KEY=... ./scripts/setup.sh" diff --git a/shared/types/src/__tests__/cds.test.ts b/shared/types/src/__tests__/cds.test.ts deleted file mode 100644 index 797d7e7..0000000 --- a/shared/types/src/__tests__/cds.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { - FhirAuthorization, - CdsContext, - CdsRequest, - CdsResponse, - CdsCard, - CdsSuggestion, - CdsAction, - CdsLink, -} from '../cds'; - -describe('CDS Types', () => { - describe('FhirAuthorization', () => { - it('FhirAuthorization_WithRequired_HasTokenFields', () => { - const auth: FhirAuthorization = { - accessToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...', - tokenType: 'Bearer', - expiresIn: 3600, - }; - expect(auth.tokenType).toBe('Bearer'); - expect(auth.expiresIn).toBe(3600); - }); - - it('FhirAuthorization_WithOptional_HasScopeAndSubject', () => { - const auth: FhirAuthorization = { - accessToken: 'token', - tokenType: 'Bearer', - expiresIn: 3600, - scope: 'patient/*.read', - subject: 'Patient/123', - }; - expect(auth.scope).toBe('patient/*.read'); - expect(auth.subject).toBe('Patient/123'); - }); - }); - - describe('CdsContext', () => { - it('CdsContext_WithRequired_HasPatientId', () => { - const context: CdsContext = { - patientId: 'Patient/123', - }; - expect(context.patientId).toBe('Patient/123'); - }); - - it('CdsContext_WithDraftOrders_HasOrderBundle', () => { - const context: CdsContext = { - patientId: 'Patient/123', - userId: 'Practitioner/456', - encounterId: 'Encounter/789', - draftOrders: { - resourceType: 'Bundle', - entry: [ - { - resource: { - resourceType: 'ServiceRequest', - id: 'sr-1', - code: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '77477000', - display: 'CT scan', - }, - ], - }, - }, - }, - ], - }, - }; - expect(context.draftOrders?.resourceType).toBe('Bundle'); - expect(context.draftOrders?.entry?.[0]?.resource?.resourceType).toBe('ServiceRequest'); - }); - }); - - describe('CdsRequest', () => { - it('CdsRequest_WithRequired_HasHookInfo', () => { - const request: CdsRequest = { - hookInstance: 'uuid-123', - hook: 'order-select', - context: { - patientId: 'Patient/123', - }, - }; - expect(request.hook).toBe('order-select'); - expect(request.hookInstance).toBe('uuid-123'); - }); - - it('CdsRequest_WithOptional_HasFhirServer', () => { - const request: CdsRequest = { - hookInstance: 'uuid-123', - hook: 'order-sign', - fhirServer: 'https://fhir.example.com/r4', - fhirAuthorization: { - accessToken: 'token', - tokenType: 'Bearer', - expiresIn: 3600, - }, - context: { - patientId: 'Patient/123', - }, - prefetch: { - patient: { resourceType: 'Patient', id: '123' }, - }, - }; - expect(request.fhirServer).toBe('https://fhir.example.com/r4'); - }); - }); - - describe('CdsResponse', () => { - it('CdsResponse_WithCards_HasCardArray', () => { - const response: CdsResponse = { - cards: [ - { - summary: 'Prior authorization required', - indicator: 'warning', - source: { label: 'AuthScript' }, - }, - ], - }; - expect(response.cards).toHaveLength(1); - expect(response.cards[0].indicator).toBe('warning'); - }); - }); - - describe('CdsCard', () => { - it('CdsCard_WithRequired_HasSummaryAndSource', () => { - const card: CdsCard = { - summary: 'Prior authorization required', - indicator: 'warning', - source: { label: 'AuthScript' }, - }; - expect(card.summary).toBe('Prior authorization required'); - expect(card.source.label).toBe('AuthScript'); - }); - - it('CdsCard_Indicator_AcceptsValidValues', () => { - const info: CdsCard['indicator'] = 'info'; - const warning: CdsCard['indicator'] = 'warning'; - const critical: CdsCard['indicator'] = 'critical'; - const hardStop: CdsCard['indicator'] = 'hard-stop'; - - expect(['info', 'warning', 'critical', 'hard-stop']).toContain(info); - expect(['info', 'warning', 'critical', 'hard-stop']).toContain(warning); - expect(['info', 'warning', 'critical', 'hard-stop']).toContain(critical); - expect(['info', 'warning', 'critical', 'hard-stop']).toContain(hardStop); - }); - - it('CdsCard_WithOptional_HasSuggestionsAndLinks', () => { - const card: CdsCard = { - uuid: 'card-uuid-123', - summary: 'Prior authorization required', - detail: 'Detailed explanation here', - indicator: 'warning', - source: { - label: 'AuthScript', - url: 'https://authscript.com', - icon: 'https://authscript.com/icon.png', - }, - suggestions: [ - { - label: 'Submit PA request', - isRecommended: true, - }, - ], - links: [ - { - label: 'View PA form', - url: 'https://authscript.com/pa/123', - type: 'absolute', - }, - ], - }; - expect(card.suggestions).toHaveLength(1); - expect(card.links).toHaveLength(1); - }); - }); - - describe('CdsSuggestion', () => { - it('CdsSuggestion_WithRequired_HasLabel', () => { - const suggestion: CdsSuggestion = { - label: 'Submit PA request', - }; - expect(suggestion.label).toBe('Submit PA request'); - }); - - it('CdsSuggestion_WithActions_HasActionArray', () => { - const suggestion: CdsSuggestion = { - label: 'Auto-fill PA form', - uuid: 'suggestion-uuid', - isRecommended: true, - actions: [ - { - type: 'create', - description: 'Create PA request', - resource: { resourceType: 'ServiceRequest' }, - }, - ], - }; - expect(suggestion.actions).toHaveLength(1); - expect(suggestion.actions?.[0].type).toBe('create'); - }); - }); - - describe('CdsAction', () => { - it('CdsAction_Type_AcceptsValidValues', () => { - const create: CdsAction['type'] = 'create'; - const update: CdsAction['type'] = 'update'; - const del: CdsAction['type'] = 'delete'; - - expect(['create', 'update', 'delete']).toContain(create); - expect(['create', 'update', 'delete']).toContain(update); - expect(['create', 'update', 'delete']).toContain(del); - }); - }); - - describe('CdsLink', () => { - it('CdsLink_WithRequired_HasLabelUrlType', () => { - const link: CdsLink = { - label: 'View details', - url: 'https://example.com', - type: 'absolute', - }; - expect(link.label).toBe('View details'); - expect(link.type).toBe('absolute'); - }); - - it('CdsLink_Type_AcceptsValidValues', () => { - const absolute: CdsLink['type'] = 'absolute'; - const smart: CdsLink['type'] = 'smart'; - - expect(['absolute', 'smart']).toContain(absolute); - expect(['absolute', 'smart']).toContain(smart); - }); - - it('CdsLink_Smart_HasAppContext', () => { - const link: CdsLink = { - label: 'Launch PA app', - url: 'https://smart.example.com/launch', - type: 'smart', - appContext: 'patient=123&orderId=456', - }; - expect(link.appContext).toBe('patient=123&orderId=456'); - }); - }); -}); diff --git a/shared/types/src/__tests__/index.test.ts b/shared/types/src/__tests__/index.test.ts index 094063b..c78068a 100644 --- a/shared/types/src/__tests__/index.test.ts +++ b/shared/types/src/__tests__/index.test.ts @@ -11,15 +11,6 @@ import type { PAFormResponse, EvidenceItem, StatusUpdate, - // CDS types - FhirAuthorization, - CdsContext, - CdsRequest, - CdsResponse, - CdsCard, - CdsSuggestion, - CdsAction, - CdsLink, } from '../index'; describe('Index Exports', () => { @@ -98,62 +89,4 @@ describe('Index Exports', () => { expect(update.status).toBe('in_progress'); }); }); - - describe('CDS Types Export', () => { - it('FhirAuthorization_ExportedFromIndex', () => { - const auth: FhirAuthorization = { - accessToken: 'token', - tokenType: 'Bearer', - expiresIn: 3600, - }; - expect(auth.tokenType).toBe('Bearer'); - }); - - it('CdsContext_ExportedFromIndex', () => { - const context: CdsContext = { patientId: 'P123' }; - expect(context.patientId).toBe('P123'); - }); - - it('CdsRequest_ExportedFromIndex', () => { - const request: CdsRequest = { - hookInstance: 'uuid', - hook: 'order-select', - context: { patientId: 'P123' }, - }; - expect(request.hook).toBe('order-select'); - }); - - it('CdsResponse_ExportedFromIndex', () => { - const response: CdsResponse = { cards: [] }; - expect(response.cards).toHaveLength(0); - }); - - it('CdsCard_ExportedFromIndex', () => { - const card: CdsCard = { - summary: 'Test', - indicator: 'info', - source: { label: 'Test' }, - }; - expect(card.indicator).toBe('info'); - }); - - it('CdsSuggestion_ExportedFromIndex', () => { - const suggestion: CdsSuggestion = { label: 'Test' }; - expect(suggestion.label).toBe('Test'); - }); - - it('CdsAction_ExportedFromIndex', () => { - const action: CdsAction = { type: 'create' }; - expect(action.type).toBe('create'); - }); - - it('CdsLink_ExportedFromIndex', () => { - const link: CdsLink = { - label: 'Test', - url: 'https://example.com', - type: 'absolute', - }; - expect(link.type).toBe('absolute'); - }); - }); }); diff --git a/shared/types/src/cds.ts b/shared/types/src/cds.ts deleted file mode 100644 index ac18cb4..0000000 --- a/shared/types/src/cds.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * CDS Hooks type definitions - * Based on HL7 CDS Hooks specification - */ - -export interface FhirAuthorization { - accessToken: string; - tokenType: string; - expiresIn: number; - scope?: string; - subject?: string; -} - -export interface CdsContext { - userId?: string; - patientId: string; - encounterId?: string; - draftOrders?: { - resourceType: string; - entry?: Array<{ - resource?: { - resourceType: string; - id?: string; - code?: { - coding?: Array<{ - system?: string; - code?: string; - display?: string; - }>; - }; - }; - }>; - }; -} - -export interface CdsRequest { - hookInstance: string; - hook: string; - fhirServer?: string; - fhirAuthorization?: FhirAuthorization; - context: CdsContext; - prefetch?: Record; -} - -export interface CdsResponse { - cards: CdsCard[]; -} - -export interface CdsCard { - uuid?: string; - summary: string; - detail?: string; - indicator: 'info' | 'warning' | 'critical' | 'hard-stop'; - source: { - label: string; - url?: string; - icon?: string; - }; - suggestions?: CdsSuggestion[]; - links?: CdsLink[]; -} - -export interface CdsSuggestion { - label: string; - uuid?: string; - isRecommended?: boolean; - actions?: CdsAction[]; -} - -export interface CdsAction { - type: 'create' | 'update' | 'delete'; - description?: string; - resource?: unknown; -} - -export interface CdsLink { - label: string; - url: string; - type: 'absolute' | 'smart'; - appContext?: string; -} diff --git a/shared/types/src/generated/intelligence.ts b/shared/types/src/generated/intelligence.ts index e278d80..e69b947 100644 --- a/shared/types/src/generated/intelligence.ts +++ b/shared/types/src/generated/intelligence.ts @@ -132,11 +132,8 @@ export type RootGet200 = {[key: string]: string}; /** * Analyze clinical data and generate PA form response. -This endpoint: -1. Validates the procedure code against supported policies -2. Extracts evidence from clinical data -3. Evaluates against policy criteria -4. Generates form field values +STUB IMPLEMENTATION: Always returns APPROVE with 1.0 confidence. +Production version would evaluate clinical data against payer policies. * @summary Analyze */ export type analyzeAnalyzePostResponse200 = { @@ -189,13 +186,8 @@ export const analyzeAnalyzePost = async (analyzeRequest: AnalyzeRequest, options /** * Analyze clinical data with attached PDF documents. -Processes multipart form data including: -- **patient_id**: Unique patient identifier -- **procedure_code**: CPT/HCPCS code for the procedure -- **clinical_data**: JSON string of clinical data -- **documents**: PDF files containing clinical documentation - -Returns the same PA form response as the standard analyze endpoint. +STUB IMPLEMENTATION: Documents are acknowledged but not processed. +Production version would extract text and analyze documents. * @summary Analyze With Documents */ export type analyzeWithDocumentsAnalyzeWithDocumentsPostResponse200 = { diff --git a/shared/types/src/index.ts b/shared/types/src/index.ts index 46a5083..d290a88 100644 --- a/shared/types/src/index.ts +++ b/shared/types/src/index.ts @@ -5,4 +5,3 @@ export * from './common'; export * from './authscript'; -export * from './cds';