From e66ac7acf09246c913be7f452f19967bd220830a Mon Sep 17 00:00:00 2001 From: Ashok Ginjala Date: Thu, 11 Jun 2026 16:35:55 -0400 Subject: [PATCH 1/7] Radiology samples: sync with source-of-truth + add Quickstart and AI sample projects - Sync radiology-extensibility-api.yaml, manifest schema, wire samples, and C# models with internal source-of-truth (envelope: replace wire schemaVersion with extensibilityApiVersion; payloads: drop nested schemaVersion; spec: add x-ms-schema-version annotations) - Update CLI manifest schema, types, prompts, init/generate commands, and quality-check template to require radiologyExtensibilityApiVersion + per-IO schemaVersion (camelCase names) - Add SampleExtension.Radiology.Web.Quickstart (mock-data) and SampleExtension.Radiology.Web.Ai (Azure OpenAI + Foundry Local) sample projects with solution file, READMEs, and .http requests - Add 8 schema-validation tests covering the new radiology contract requirements - Refresh radiology README versioning section and per-sample contract notes for partner clarity --- radiology/Directory.Packages.props | 10 + radiology/README.md | 8 + radiology/radiology-extensibility-api.yaml | 44 ++-- .../{PatientInfo.cs => PatientInformation.cs} | 6 +- .../ProcessRequest.cs | 46 +++- .../ProcessResponse.cs | 41 ++-- .../Recommendation.cs | 4 +- .../SessionData.cs | 14 +- radiology/src/samples/Workflow/README.md | 35 +++ .../Configuration/AuthenticationOptions.cs | 40 ++++ .../Configuration/FoundryLocalSettings.cs | 40 ++++ .../Configuration/OpenAiSettings.cs | 27 +++ .../Controllers/QualityCheckController.cs | 71 ++++++ .../Extensions/ServiceCollectionExtensions.cs | 160 +++++++++++++ .../Extensions/WebApplicationExtensions.cs | 28 +++ .../Program.cs | 78 +++++++ .../Properties/launchSettings.json | 23 ++ .../README.md | 214 ++++++++++++++++++ .../SampleExtension.Radiology.Web.Ai.csproj | 34 +++ .../SampleExtension.Radiology.Web.Ai.http | 28 +++ .../Services/AzureOpenAIService.cs | 50 ++++ .../Services/FoundryLocalService.cs | 197 ++++++++++++++++ .../Services/IAzureOpenAIService.cs | 20 ++ .../Services/IFoundryLocalService.cs | 18 ++ .../Services/IQualityCheckService.cs | 13 ++ .../Services/QualityCheckService.cs | 199 ++++++++++++++++ .../appsettings.Development.json | 10 + .../appsettings.json | 33 +++ .../nuget.config | 7 + .../Configuration/AuthenticationOptions.cs | 40 ++++ .../Controllers/QualityCheckController.cs | 70 ++++++ .../Extensions/ServiceCollectionExtensions.cs | 159 +++++++++++++ .../Extensions/WebApplicationExtensions.cs | 28 +++ .../MockData/qualitycheck-response.json | 49 ++++ .../Program.cs | 69 ++++++ .../Properties/launchSettings.json | 23 ++ .../README.md | 146 ++++++++++++ ...eExtension.Radiology.Web.Quickstart.csproj | 35 +++ ...pleExtension.Radiology.Web.Quickstart.http | 28 +++ .../Services/IQualityCheckService.cs | 13 ++ .../Services/QualityCheckService.cs | 101 +++++++++ .../appsettings.Development.json | 10 + .../appsettings.json | 24 ++ .../nuget.config | 7 + .../SampleExtensions.Radiology.Web.slnx | 5 + .../samples/requests/FullRequest-Example.json | 4 +- ...=> PatientInformationRequest-Example.json} | 4 +- .../QualityCheckResultResponse-Example.json | 1 - .../requests/ReportRequest-Example.json | 2 +- .../requests/sample-requests-responses.md | 13 +- .../src/__tests__/cli-integration.test.ts | 14 +- .../src/__tests__/manifest-validation.test.ts | 132 ++++++++++- .../domains/radiology/commands/generate.ts | 7 +- .../src/domains/radiology/commands/init.ts | 6 +- .../src/domains/radiology/shared/prompts.ts | 42 +++- .../src/domains/radiology/templates/index.ts | 22 +- .../src/domains/radiology/types.ts | 6 + .../radiology-extension-manifest-schema.json | 39 +++- 58 files changed, 2479 insertions(+), 118 deletions(-) create mode 100644 radiology/Directory.Packages.props rename radiology/src/models/Dragon.Copilot.Radiology.Models/{PatientInfo.cs => PatientInformation.cs} (81%) create mode 100644 radiology/src/samples/Workflow/README.md create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/AuthenticationOptions.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/FoundryLocalSettings.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/OpenAiSettings.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Controllers/QualityCheckController.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/ServiceCollectionExtensions.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/WebApplicationExtensions.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Program.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Properties/launchSettings.json create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/README.md create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.csproj create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.http create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/AzureOpenAIService.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/FoundryLocalService.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IAzureOpenAIService.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IFoundryLocalService.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IQualityCheckService.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/QualityCheckService.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/appsettings.Development.json create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/appsettings.json create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/nuget.config create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Configuration/AuthenticationOptions.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Controllers/QualityCheckController.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Extensions/ServiceCollectionExtensions.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Extensions/WebApplicationExtensions.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/MockData/qualitycheck-response.json create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Program.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Properties/launchSettings.json create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/README.md create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/SampleExtension.Radiology.Web.Quickstart.csproj create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/SampleExtension.Radiology.Web.Quickstart.http create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/IQualityCheckService.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/QualityCheckService.cs create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/appsettings.Development.json create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/appsettings.json create mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/nuget.config create mode 100644 radiology/src/samples/Workflow/SampleExtensions.Radiology.Web.slnx rename radiology/src/samples/requests/{PatientInfoRequest-Example.json => PatientInformationRequest-Example.json} (80%) diff --git a/radiology/Directory.Packages.props b/radiology/Directory.Packages.props new file mode 100644 index 0000000..1c99e18 --- /dev/null +++ b/radiology/Directory.Packages.props @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/radiology/README.md b/radiology/README.md index 64b8687..889ee70 100644 --- a/radiology/README.md +++ b/radiology/README.md @@ -25,6 +25,14 @@ Key resources: | ---------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | **Radiology Workflow** | Custom AI-powered extensions with automation scripts, event triggers, and dependencies | Extend Dragon Copilot with custom radiology data processing | +### Versioning + +Three independent version axes appear in these artifacts. They are **declarations recorded at manifest upload time** — none is transmitted on each `POST /v1/process` request: + +- **API version** — `info.version` in [`radiology-extensibility-api.yaml`](radiology-extensibility-api.yaml) (semantic `x.y.z`). The version of the extensibility API contract as a whole. A Partner records the version they built against in their manifest's `radiologyExtensibilityApiVersion` field. +- **Extension version** — the manifest's top-level `version` field (`x.y.z`). The Partner's own product version for their extension, independent of the API version. +- **Payload schema version** — each payload schema (`Report`, `PatientInformation`, `QualityCheckResult`) declares its own version via the `x-ms-schema-version` annotation in [`radiology-extensibility-api.yaml`](radiology-extensibility-api.yaml) (`major.minor`). The Partner declares which version of each payload they accept (inputs) or produce (outputs) via the required `schemaVersion` field on every input and output in their manifest. This gives per-payload traceability — e.g. "this extension accepts `Report` v1.0" — without putting a version on the wire payloads themselves. + ## 🚀 Getting Started For repo setup, cloning instructions, and contributing guidelines, see the [root README](../README.md). diff --git a/radiology/radiology-extensibility-api.yaml b/radiology/radiology-extensibility-api.yaml index 34a06e2..f34c4dc 100644 --- a/radiology/radiology-extensibility-api.yaml +++ b/radiology/radiology-extensibility-api.yaml @@ -3,7 +3,7 @@ openapi: 3.0.0 info: title: Radiology Extensibility API - version: 0.0.1 + version: 1.0.0 description: API definition for enabling Dragon Copilot for Radiology extension integrations paths: @@ -104,36 +104,25 @@ components: schemas: ProcessRequest: type: object - description: >- - Request envelope for /v1/process. + description: Request envelope for /v1/process. required: - - schemaVersion - sessionData properties: - schemaVersion: + extensibilityApiVersion: type: string - description: >- - Wire-level contract version of the Radiology Extensibility API envelope. - Mirrors the MAJOR.MINOR of `info.version`. - example: "0.0" + description: Dragon Copilot Extensibility API version. + example: "1.1.1" sessionData: $ref: "#/components/schemas/SessionData" - additionalProperties: - oneOf: - - $ref: "#/components/schemas/PatientInfo" - - $ref: "#/components/schemas/Report" + patientInformation: + $ref: "#/components/schemas/PatientInformation" + report: + $ref: "#/components/schemas/Report" + additionalProperties: true ProcessResponse: type: object - required: - - schemaVersion properties: - schemaVersion: - type: string - description: >- - Wire-level contract version of the Radiology Extensibility API envelope. - Mirrors the MAJOR.MINOR of `info.version`. - example: "0.0" success: type: boolean example: true @@ -142,9 +131,7 @@ components: example: "Payload processed successfully" payload: type: object - description: >- - Map of named outputs. Each key is the output name declared in the extension - manifest, and its value is a QualityCheckResult. + description: Map of named outputs, keyed by output name from the extension manifest. additionalProperties: $ref: "#/components/schemas/QualityCheckResult" @@ -159,16 +146,17 @@ components: session_start: type: string format: date-time - description: Session start timestamp. + description: Session start timestamp. Optional; may be absent. example: "2025-06-20T08:57:35.978Z" environment_id: type: string description: Environment identifier. example: "01bd0d47-1621-4a29-941d-00e9a9420f20" - PatientInfo: + PatientInformation: type: object description: Patient demographic information. + x-ms-schema-version: "1.0" properties: dateOfBirth: type: string @@ -187,6 +175,7 @@ components: Report: type: object + x-ms-schema-version: "1.0" required: - reportText properties: @@ -197,12 +186,13 @@ components: QualityCheckResult: type: object + x-ms-schema-version: "1.0" required: - recommendations properties: recommendations: type: array - description: An array of billing quality check recommendations. This will be an empty list if there are no recommendations. + description: An array of quality check recommendations (each typed via Recommendation.qualityCheckType, e.g. "Billing" or "Clinical"). This will be an empty list if there are no recommendations. items: $ref: "#/components/schemas/Recommendation" diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/PatientInfo.cs b/radiology/src/models/Dragon.Copilot.Radiology.Models/PatientInformation.cs similarity index 81% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/PatientInfo.cs rename to radiology/src/models/Dragon.Copilot.Radiology.Models/PatientInformation.cs index 2b9f0b3..5219216 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/PatientInfo.cs +++ b/radiology/src/models/Dragon.Copilot.Radiology.Models/PatientInformation.cs @@ -6,18 +6,18 @@ namespace Dragon.Copilot.Radiology.Models /// Patient demographic information. /// /// - /// Corresponds to the PatientInfo schema defined in radiology-extensibility-api.yaml. + /// Corresponds to the PatientInformation schema defined in radiology-extensibility-api.yaml. /// /// /// - /// var patientInfo = new PatientInfo + /// var patientInformation = new PatientInformation /// { /// DateOfBirth = new DateOnly(1990, 1, 15), /// BiologicalSex = BiologicalSex.Male, /// }; /// /// - public class PatientInfo + public class PatientInformation { /// /// Gets or sets the date of birth of the patient in YYYY-MM-DD format. diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessRequest.cs b/radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessRequest.cs index 897f76a..867c9ca 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessRequest.cs +++ b/radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessRequest.cs @@ -1,38 +1,60 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json; using System.Text.Json.Serialization; namespace Dragon.Copilot.Radiology.Models; /// -/// DCR Extensibility API request envelope that carries session metadata and a map of named -/// input payloads. +/// Request envelope for the /v1/process endpoint. /// +/// +/// Corresponds to the ProcessRequest schema defined in radiology-extensibility-api.yaml. +/// The envelope allows additional named inputs beyond and +/// — they are surfaced via . +/// +/// +/// +/// var request = new ProcessRequest +/// { +/// ExtensibilityApiVersion = "1.1.1", +/// SessionData = new SessionData { CorrelationId = "..." }, +/// PatientInformation = new PatientInformation { DateOfBirth = new DateOnly(1990, 1, 15) }, +/// Report = new Report { ReportText = "..." }, +/// }; +/// +/// public class ProcessRequest { /// - /// Contract version. + /// Gets or sets the transport version emitted by Dragon Copilot (e.g., "1.1.1"). /// - [Required(AllowEmptyStrings = false)] - [JsonPropertyName("schemaVersion")] - public string SchemaVersion { get; set; } = null!; + [JsonPropertyName("extensibilityApiVersion")] + public string? ExtensibilityApiVersion { get; set; } /// - /// Session context for request correlation and tracking. + /// Gets or sets the session context for request correlation and tracking. /// [Required] [JsonPropertyName("sessionData")] public SessionData SessionData { get; set; } = null!; /// - /// Patient demographics. The JSON property name (patientInfo) must match the - /// input name declared in the extension manifest. + /// Gets or sets the patient demographic information, if present. /// - [JsonPropertyName("patientInfo")] - public PatientInfo? PatientInfo { get; set; } + [JsonPropertyName("patientInformation")] + public PatientInformation? PatientInformation { get; set; } /// - /// The radiology report to analyze. + /// Gets or sets the radiology report, if present. /// [JsonPropertyName("report")] public Report? Report { get; set; } + + /// + /// Gets or sets any additional named inputs not covered by the explicit properties above. + /// Corresponds to the schema's additionalProperties: true allowance. + /// + [JsonExtensionData] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Needed for deserialization of partner payloads")] + public Dictionary? AdditionalProperties { get; set; } } diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessResponse.cs b/radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessResponse.cs index 0f06cf1..84f6d41 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessResponse.cs +++ b/radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessResponse.cs @@ -1,35 +1,46 @@ -using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; namespace Dragon.Copilot.Radiology.Models; /// -/// Envelope returned by POST /v1/process. +/// Response envelope for the /v1/process endpoint. /// +/// +/// Corresponds to the ProcessResponse schema defined in radiology-extensibility-api.yaml. +/// The is a map of named outputs (e.g., "qualityCheckResult"), each value +/// being a . Output names are declared in the extension's manifest. +/// +/// +/// +/// var response = new ProcessResponse +/// { +/// Success = true, +/// Message = "Quality check completed successfully.", +/// Payload = new Dictionary<string, QualityCheckResult> +/// { +/// ["qualityCheckResult"] = new QualityCheckResult { Recommendations = ... }, +/// }, +/// }; +/// +/// public class ProcessResponse { /// - /// Contract version. - /// - [Required(AllowEmptyStrings = false)] - [JsonPropertyName("schemaVersion")] - public string SchemaVersion { get; set; } = null!; - - /// - /// Indicates if the processing was successful + /// Gets or sets a value indicating whether the extension completed processing successfully. /// [JsonPropertyName("success")] - public bool Success { get; set; } + public bool? Success { get; set; } /// - /// Processing result message + /// Gets or sets the human-readable status message. /// [JsonPropertyName("message")] - public string Message { get; set; } = string.Empty; + public string? Message { get; set; } /// - /// The processed payload data containing DSP responses + /// Gets or sets the map of named outputs, keyed by output name from the extension manifest. /// [JsonPropertyName("payload")] - public IDictionary Payload { get; } = new Dictionary(); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Needed for deserialization of partner payloads")] + public Dictionary? Payload { get; set; } } diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/Recommendation.cs b/radiology/src/models/Dragon.Copilot.Radiology.Models/Recommendation.cs index dbab537..7887caf 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/Recommendation.cs +++ b/radiology/src/models/Dragon.Copilot.Radiology.Models/Recommendation.cs @@ -5,7 +5,7 @@ namespace Dragon.Copilot.Radiology.Models { /// - /// A quality check recommendation produced by a DCR extension. + /// A quality check recommendation produced by an extension. /// /// /// Corresponds to the Recommendation schema defined in radiology-extensibility-api.yaml. @@ -66,7 +66,7 @@ public class Recommendation public string Reason { get; set; } = null!; /// - /// Gets or sets the severity of the recommendation as a percentage (0–100). + /// Gets or sets the severity of the recommendation as a percentage (0-100). /// [JsonPropertyName("severityScorePercent")] public double? SeverityScorePercent { get; set; } diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/SessionData.cs b/radiology/src/models/Dragon.Copilot.Radiology.Models/SessionData.cs index c69370c..bac9361 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/SessionData.cs +++ b/radiology/src/models/Dragon.Copilot.Radiology.Models/SessionData.cs @@ -4,24 +4,30 @@ namespace Dragon.Copilot.Radiology.Models; /// -/// Session / correlation information forwarded by the host application. +/// Session context for request correlation and tracking. /// +/// +/// Corresponds to the SessionData schema defined in radiology-extensibility-api.yaml. +/// JSON property names on this type are snake_case (e.g. correlation_id) by design, +/// inherited from the upstream Dragon SessionData contract. The rest of the radiology +/// extensibility schemas use camelCase. +/// public class SessionData { /// - /// Correlation identifier used to trace the request across services. + /// Gets or sets the unique session correlation identifier. /// [JsonPropertyName("correlation_id")] public string? CorrelationId { get; set; } /// - /// ISO-8601 timestamp of when the session started (optional). + /// Gets or sets the session start timestamp. Optional; may be absent. /// [JsonPropertyName("session_start")] public DateTime? SessionStart { get; set; } /// - /// Identifier of the environment the request originated from. + /// Gets or sets the environment identifier. /// [JsonPropertyName("environment_id")] public string? EnvironmentId { get; set; } diff --git a/radiology/src/samples/Workflow/README.md b/radiology/src/samples/Workflow/README.md new file mode 100644 index 0000000..d362aef --- /dev/null +++ b/radiology/src/samples/Workflow/README.md @@ -0,0 +1,35 @@ +[← Radiology product overview](../../../README.md) + +# Dragon Copilot — Radiology Extension Samples (Workflow) + +This folder contains ASP.NET Core sample projects that demonstrate the +partner extension pattern for Dragon Copilot. + +| Project | Purpose | Default port (http/https) | Target | +| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------- | +| [`SampleExtension.Radiology.Web.Quickstart`](./SampleExtension.Radiology.Web.Quickstart/README.md) | Returns a canned response loaded from `MockData/qualitycheck-response.json`. No model inference, no AI dependencies. | 5080 / 7080 | `net10.0` (cross-platform) | +| [`SampleExtension.Radiology.Web.Ai`](./SampleExtension.Radiology.Web.Ai/README.md) | Uses Azure OpenAI when configured, falls back to an on-device Foundry Local model otherwise. | 5080 / 7080 | `net10.0-windows10.0.26100` (Windows-only, required by Foundry Local) | + + +## Solution + +[`SampleExtensions.Radiology.Web.slnx`](./SampleExtensions.Radiology.Web.slnx) +contains the sample projects plus the shared +[`Dragon.Copilot.Radiology.Models`](../../models/Dragon.Copilot.Radiology.Models/Dragon.Copilot.Radiology.Models.csproj) +contract project. + +## Build everything + +```powershell +dotnet build SampleExtensions.Radiology.Web.slnx +``` + +## Run a sample + +```powershell +# Stub-only (mock data) +dotnet run --project SampleExtension.Radiology.Web.Quickstart + +# AI-backed (Azure OpenAI + Foundry Local fallback) +dotnet run --project SampleExtension.Radiology.Web.Ai +``` diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/AuthenticationOptions.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/AuthenticationOptions.cs new file mode 100644 index 0000000..84d93d1 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/AuthenticationOptions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace SampleExtension.Radiology.Web.Ai.Configuration; + +/// +/// JWT Authentication configuration options. +/// +public class AuthenticationOptions +{ + /// + /// The configuration section name. + /// + public const string SectionName = "Authentication"; + + /// + /// Whether authentication is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Tenant ID for the application. + /// + public string? TenantId { get; set; } + + /// + /// Client ID for the application. + /// + public string? ClientId { get; set; } + + /// + /// Login instance (e.g., "https://login.microsoftonline.com/"). + /// + public string Instance { get; set; } = "https://login.microsoftonline.com/"; + + /// + /// Required claims that must be present in JWT tokens. + /// + public IDictionary> RequiredClaims { get; } = new Dictionary>(); +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/FoundryLocalSettings.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/FoundryLocalSettings.cs new file mode 100644 index 0000000..288d7dc --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/FoundryLocalSettings.cs @@ -0,0 +1,40 @@ +namespace SampleExtension.Radiology.Web.Ai.Configuration; + +/// +/// Settings for Microsoft.AI.Foundry.Local on-device model inference. +/// Used when Azure OpenAI is not configured. +/// +public class FoundryLocalSettings +{ + /// + /// Configuration section name for Foundry Local settings. + /// + public const string SectionName = "FoundryLocal"; + + /// + /// When true, the Foundry Local provider is used if Azure OpenAI is not configured. + /// + public bool Enabled { get; set; } = true; + + /// + /// Foundry Local model alias to download and load. + /// Alternatives: qwen2.5-0.5b, phi-3.5-mini, phi-4-mini, mistral-7b, gpt-oss-20b. + /// + public string ModelAlias { get; set; } = "qwen2.5-1.5b"; + + /// + /// Hardware device used for inference. Valid values: CPU, GPU, NPU. + /// + public string DeviceType { get; set; } = "CPU"; + + /// + /// Application name passed to FoundryLocalManager. Used for log/data directory naming. + /// + public string AppName { get; set; } = "DragonCopilot-Radiology-Sample"; + + /// + /// Local directory used by Foundry Local for the model cache and logs. + /// When null or empty, defaults to ~/.foundry. Override with an absolute path to use a different location. + /// + public string AppDataDir { get; set; } = string.Empty; +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/OpenAiSettings.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/OpenAiSettings.cs new file mode 100644 index 0000000..8e6d476 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/OpenAiSettings.cs @@ -0,0 +1,27 @@ +namespace SampleExtension.Radiology.Web.Ai.Configuration; + +/// +/// Settings for OpenAI configuration. +/// +public class OpenAiSettings +{ + /// + /// Configuration section name for OpenAI settings. + /// + public const string SectionName = "OpenAI"; + + /// + /// Endpoint for the OpenAI service. + /// + public string Endpoint { get; set; } = string.Empty; + + /// + /// API key for authenticating with the OpenAI service. + /// + public string ApiKey { get; set; } = string.Empty; + + /// + /// Deployment name of the OpenAI model to be used. + /// + public string DeploymentName { get; set; } = string.Empty; +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Controllers/QualityCheckController.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Controllers/QualityCheckController.cs new file mode 100644 index 0000000..e1969aa --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Controllers/QualityCheckController.cs @@ -0,0 +1,71 @@ +using Dragon.Copilot.Radiology.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SampleExtension.Radiology.Web.Ai.Services; +using System.Text.Json; + +namespace SampleExtension.Radiology.Web.Ai.Controllers; + +/// +/// Single entry point of the Radiology simple extension. +/// Demonstrates a single-endpoint extension with model binding +/// performed by the framework and no authentication. +/// +[ApiController] +[Route("v1")] +[Produces("application/json")] +[Authorize(Policy = "RequiredClaims")] +public sealed class QualityCheckController : ControllerBase +{ + private readonly IQualityCheckService _qualityCheckService; + private readonly ILogger _logger; + + public QualityCheckController(IQualityCheckService qualityCheckService, ILogger logger) + { + ArgumentNullException.ThrowIfNull(qualityCheckService); + ArgumentNullException.ThrowIfNull(logger); + + _qualityCheckService = qualityCheckService; + _logger = logger; + } + + /// + /// Analyzes a radiology report and returns a list of quality-check recommendations. + /// + /// + /// This sample uses Azure OpenAI when configured, falling back to an on-device + /// Foundry Local model. Replace with + /// your real implementation. + /// + [HttpPost("process")] + [ProducesResponseType(typeof(ProcessResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public ActionResult Post([FromBody] ProcessRequest payload) + { + ArgumentNullException.ThrowIfNull(payload); + + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + _logger.LogInformation( + "Received {Method} {Path} - CorrelationId={CorrelationId}", + Request.Method, + Request.Path, + payload.SessionData.CorrelationId); + + var result = _qualityCheckService.Process(payload); + + _logger.LogInformation( + "Response {Method} {Path} - Success: {Success} - Message: {Message} - Response Body: {ResponseBody}", + Request.Method, + Request.Path, + result.Success, + result.Message, + JsonSerializer.Serialize(result)); + + return Ok(result); + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/ServiceCollectionExtensions.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6e7e960 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Identity.Web; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using SampleExtension.Radiology.Web.Ai.Configuration; +using AuthOptions = SampleExtension.Radiology.Web.Ai.Configuration.AuthenticationOptions; + +namespace SampleExtension.Radiology.Web.Ai.Extensions; + +/// +/// Extension methods for service collection configuration. +/// +public static class ServiceCollectionExtensions +{ + private static readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + private static readonly ILogger _logger = _loggerFactory.CreateLogger(nameof(ServiceCollectionExtensions)); + + /// + /// Adds custom JWT authentication and claims-based authorization services. + /// + public static IServiceCollection AddCustomAuthentication(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + var authOptions = configuration.GetSection(AuthOptions.SectionName).Get(); + + // If authentication is disabled, add policies that always allow access + if (authOptions?.Enabled != true) + { + _logger.LogWarning("JWT authentication is disabled. All requests will be allowed without token validation."); + + services.AddAuthorization(options => + { + options.AddPolicy("RequiredClaims", policy => + policy.RequireAssertion(_ => true)); + }); + + return services; + } + + // Add JWT authentication + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(configuration.GetSection(AuthOptions.SectionName)); + + // Configure JWT Bearer events for diagnostics logging + services.Configure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.Events = new JwtBearerEvents + { + OnAuthenticationFailed = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + + if (context.Exception is SecurityTokenInvalidAudienceException audienceException) + { + var authHeader = context.Request.Headers["Authorization"].ToString(); + string? token = null; + + if (!string.IsNullOrEmpty(authHeader) && + AuthenticationHeaderValue.TryParse(authHeader, out var headerValue) && + string.Equals(headerValue.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase)) + { + token = headerValue.Parameter; + } + + if (!string.IsNullOrEmpty(token)) + { + try + { + var handler = new JsonWebTokenHandler(); + var jsonToken = handler.ReadJsonWebToken(token); + var actualAudience = jsonToken.GetClaim("aud")?.Value ?? "null"; + var expectedAudience = authOptions.ClientId ?? "null"; + + logger.LogWarning( + audienceException, + "JWT audience validation failed. Actual={ActualAudience}, Expected={ExpectedAudience}, Message={Message}", + actualAudience, + expectedAudience, + audienceException.Message); + } + catch (ArgumentException ex) + { + logger.LogWarning(ex, "Failed to parse JWT token for diagnostics: {Message}", ex.Message); + } + } + } + else + { + logger.LogWarning(context.Exception, "JWT authentication failed: {Message}", context.Exception.Message); + } + + return Task.CompletedTask; + }, + OnTokenValidated = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + var principal = context.Principal; + + if (principal?.Identity?.IsAuthenticated == true) + { + logger.LogDebug("JWT token validated successfully."); + + if (authOptions.RequiredClaims.Count != 0) + { + foreach (var requiredClaim in authOptions.RequiredClaims) + { + var actualValues = principal.Claims + .Where(c => c.Type == requiredClaim.Key) + .Select(c => c.Value); + logger.LogDebug( + "Claim validation: {ClaimType} expected=[{Expected}] actual=[{Actual}]", + requiredClaim.Key, + string.Join(", ", requiredClaim.Value), + string.Join(", ", actualValues)); + } + } + } + + return Task.CompletedTask; + }, + }; + }); + + // Add authorization with custom policies + services.AddAuthorization(options => + { + options.AddPolicy("RequiredClaims", policy => + { + policy.RequireAuthenticatedUser(); + + if (authOptions.RequiredClaims.Count != 0) + { + foreach (var claim in authOptions.RequiredClaims) + { + policy.RequireClaim(claim.Key, claim.Value); + } + } + }); + }); + + return services; + } + + /// + /// Registers configuration option classes for authentication. + /// + public static IServiceCollection AddSecurityOptions(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + services.Configure(configuration.GetSection(AuthOptions.SectionName)); + + return services; + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/WebApplicationExtensions.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/WebApplicationExtensions.cs new file mode 100644 index 0000000..89bfd20 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace SampleExtension.Radiology.Web.Ai.Extensions; + +/// +/// Extension methods for configuring the web application security pipeline. +/// +internal static class WebApplicationExtensions +{ + private static readonly string[] PublicRoutes = ["/health", "/v1/health", "/index.html"]; + + /// + /// Applies JWT authentication and authorization to all non-public routes. + /// + internal static WebApplication UseFullSecurity(this WebApplication app) + { + app.UseWhen( + context => !PublicRoutes.Any(r => (context.Request.Path.Value ?? string.Empty).StartsWith(r, StringComparison.OrdinalIgnoreCase)), + protectedBranch => + { + protectedBranch.UseAuthentication(); + protectedBranch.UseAuthorization(); + }); + + return app; + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Program.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Program.cs new file mode 100644 index 0000000..8e9b201 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Program.cs @@ -0,0 +1,78 @@ +// Minimal, self-contained Radiology extension sample. +// Partners can copy this project folder and run it with `dotnet run`. + +using SampleExtension.Radiology.Web.Ai.Configuration; +using SampleExtension.Radiology.Web.Ai.Extensions; +using SampleExtension.Radiology.Web.Ai.Services; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +// OpenAI configuration +builder.Services.Configure(builder.Configuration.GetSection(OpenAiSettings.SectionName)); + +// Foundry Local (on-device model) configuration. Used as a fallback when Azure OpenAI is not configured. +builder.Services.Configure(builder.Configuration.GetSection(FoundryLocalSettings.SectionName)); + +// Services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// JWT authentication (Microsoft Entra ID). +// Toggle on/off via the "Authentication" config section. +builder.Services.AddCustomAuthentication(builder.Configuration); +builder.Services.AddSecurityOptions(builder.Configuration); + +builder.Services + .AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + }); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo + { + Title = "Simple Radiology Extension API", + Version = "v1", + Description = "A simple radiology extension sample that demonstrates the extension pattern for Dragon Copilot." + }); +}); +builder.Services.AddHealthChecks(); + +// CORS is fully open here for easy local testing. Make sure to restrict this for production. +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => policy + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader()); +}); + +var app = builder.Build(); + +app.UseCors(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Simple Radiology Extension API v1"); + c.RoutePrefix = string.Empty; // Serve Swagger UI at the app's root. + }); +} + +app.MapHealthChecks("/health/liveness"); +app.MapHealthChecks("/health/readiness"); + +// Apply JWT authentication to all non-public routes +app.UseFullSecurity(); + +app.MapControllers(); + +app.Run(); diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Properties/launchSettings.json b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Properties/launchSettings.json new file mode 100644 index 0000000..7ecfe2c --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7080;http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/README.md b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/README.md new file mode 100644 index 0000000..0ecda93 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/README.md @@ -0,0 +1,214 @@ +# Partner Extension Sample — AI + +A radiology extension for Dragon Copilot that performs **AI-powered** +quality checks. This sample wires up Azure OpenAI for cloud inference +and falls back to an on-device Foundry Local model when Azure OpenAI +is not configured. Use it as the starting point for partners that need +real model inference; copy the project, replace the prompt and result +handling with your own implementation, and deploy a working extension +that follows the expected contract. + +## What's included + +- ASP.NET Core Web API (.NET 10, Windows-only because of Foundry Local), single controller: `POST /v1/process` +- JWT authentication via Microsoft Entra ID, toggleable via `Authentication.Enabled` in `appsettings.json` +- AI-powered quality checks via Azure OpenAI **or** an on-device model through Microsoft.AI.Foundry.Local +- Swagger UI at the app root in Development +- Health probes at `/health/liveness` and `/health/readiness` + +## Run locally + +```powershell +dotnet run --project SampleExtension.Radiology.Web.Ai +``` + +Available endpoints: + +- Swagger UI: http://localhost:5080/ +- Health: `/health/liveness`, `/health/readiness` + +A `.http` file (`SampleExtension.Radiology.Web.Ai.http`) is included for +sending sample requests from Visual Studio or VS Code. + +## Security + +The application validates JWT bearer tokens on all routes except health probes +and Swagger UI. Authentication is configurable via `appsettings.json`. + +### JWT Authentication (Microsoft Entra ID) + +Validates that every incoming request carries a valid Bearer token issued by +the configured Entra ID tenant. Requests with a missing, expired, or invalid +token receive a **401 Unauthorized** response. + +Configure in the `Authentication` section of `appsettings.json`: + +| Setting | Description | +| ---------------- | --------------------------------- | +| `Enabled` | Enable or disable authentication | +| `TenantId` | Your Entra ID tenant ID | +| `ClientId` | Your app registration's client ID | +| `Instance` | Login endpoint | +| `RequiredClaims` | Additional claims to enforce | + +### Local development + +When you first clone or download the repository, authentication is disabled by +default so the API can be called without tokens. + +See [`appsettings.json`](./appsettings.json) for the full schema, defaults, +and inline comments describing each setting. + +### Enabling security for production + +To enable security: + +1. Register an application in [Microsoft Entra ID](https://entra.microsoft.com/). +2. Set `Authentication.Enabled` to `true` and populate `TenantId`, `ClientId`, + and allowed caller client IDs in `RequiredClaims.azp`. + +Example with security enabled: + +```jsonc +{ + "Authentication": { + "Enabled": true, + "TenantId": "00000000-0000-0000-0000-000000000000", + "ClientId": "11111111-1111-1111-1111-111111111111", + "Instance": "https://login.microsoftonline.com/", + "MapInboundClaims": false, + "AllowWebApiToBeAuthorizedByACL": true, + "RequiredClaims": { + "idtyp": ["app"], + "azp": [ + "22222222-2222-2222-2222-222222222222", + "33333333-3333-3333-3333-333333333333", + ], + }, + }, +} +``` + +Once enabled, callers must include the bearer token on every request: + +``` +Authorization: Bearer +``` + +## Quality check provider + +The extension performs the AI-powered quality check using one of two providers, +selected automatically in this priority order: + +1. **Azure OpenAI** — used when `OpenAI.Endpoint`, `OpenAI.ApiKey`, and + `OpenAI.DeploymentName` are all set in `appsettings.json`. +2. **Foundry Local** — used when Azure OpenAI is not configured and + `FoundryLocal.Enabled` is `true`. Runs an on-device model with no cloud + calls. Enabled by default so the sample runs out-of-the-box (the model + downloads on first use). + +If neither provider is available (Azure OpenAI not configured **and** +`FoundryLocal.Enabled` is `false`), the service throws an +`InvalidOperationException` on the first request — by design, so misconfigured +deployments fail fast rather than silently returning empty results. + +## Azure OpenAI Configuration + +The extension can use Azure OpenAI for AI-powered quality checks. Deploy your +own model and update the `OpenAI` section in `appsettings.json`. + +### Setting up Azure AI Foundry and deploying a model + +To configure the AI-powered quality check, you need an Azure OpenAI model deployment. + +1. **Create an Azure AI Foundry resource** and **deploy a model** (e.g., `gpt-4o-mini`). + Follow the official guide: + [Deploy Microsoft Foundry Models in the Foundry portal](https://learn.microsoft.com/azure/foundry/foundry-models/how-to/deploy-foundry-models) + +2. **Get the endpoint and API key** from your resource's **Keys and Endpoint** page + in the Azure Portal. + +3. **Update `appsettings.json`** — replace the placeholder values in the `OpenAI` section: + + ```jsonc + "OpenAI": { + "Endpoint": "https://.openai.azure.com/", + "ApiKey": "", + "DeploymentName": "" + } + ``` + +For production deployments, store the API key in a secure location such as +Azure Key Vault or environment variables rather than in `appsettings.json`. + +## Foundry Local (on-device model) Configuration + +Foundry Local runs a small language model directly on the host machine with no +cloud dependency. Requires Windows 10 build 26100 or later. + +Configure in the `FoundryLocal` section of `appsettings.json`: + +| Setting | Description | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Enabled` | When `true`, Foundry Local is used if Azure OpenAI is not configured. Defaults to `true` so the sample runs out-of-the-box. Set to `false` to require Azure OpenAI configuration. | +| `ModelAlias` | Foundry Local model alias to download and load. Defaults to `qwen2.5-1.5b`. Other options: `qwen2.5-0.5b`, `phi-3.5-mini`, `phi-4-mini`, `mistral-7b`, `gpt-oss-20b`. | +| `DeviceType` | `CPU`, `GPU`, or `NPU`. Defaults to `CPU` so the sample runs on machines without a GPU/NPU. | +| `AppName` | Application name passed to Foundry Local; used for log/data directory naming. | +| `AppDataDir` | Local directory for the model cache and logs. Empty (default) uses `%USERPROFILE%\.foundry` so the cache is shared with other Foundry Local apps and tools on the same machine. Set an absolute path to override. | + +### First-run behavior + +On the first request that uses Foundry Local, the configured model is +downloaded into the local cache and loaded into memory before inference runs. +Depending on model size and network speed, this can take from several seconds +to several minutes. + +The first request takes time while the model downloads and loads. You can +send request using an HTTP client tool such as Bruno, or use the included `.http` +file — in that case, raise the request timeout (for example by adding +`# @timeout 600` above the request) so the model has time to download and +load. + +Subsequent requests reuse the loaded model and respond quickly. + +## Request / response contract + +See [`radiology-extensibility-api.yaml`](../../../../radiology-extensibility-api.yaml) for the full OpenAPI spec. + +Only `sessionData` is required. `extensibilityApiVersion` shows which Dragon Copilot API version sent the request, and your extension does not need to read it. Extra fields are accepted, so your extension keeps working as the API evolves. + +**`POST /v1/process`** with `application/json` + +```jsonc +{ + "extensibilityApiVersion": "1.1.1", + "sessionData": { + "correlation_id": "abc-123", + "session_start": "2025-01-01T10:00:00Z", + "environment_id": "env-456", + }, + "patientInformation": { + "dateOfBirth": "1980-05-12", + "biologicalSex": "Female", + }, + "report": { + "reportText": "CT ABDOMEN … paddock steatosis …", + }, +} +``` + +Response: + +```jsonc +{ + "success": true, + "message": "Payload processed successfully.", + "payload": { + "qualityCheckResult": { + "recommendations": [ + /* ... */ + ], + }, + }, +} +``` diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.csproj b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.csproj new file mode 100644 index 0000000..bb98752 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.csproj @@ -0,0 +1,34 @@ + + + + net10.0-windows10.0.26100 + win-x64 + enable + enable + SampleExtension.Radiology.Web.Ai + SampleExtension.Radiology.Web.Ai + true + + CA1812;CA1515;CA1848;CA1873;CS1591;CA2227 + + + + + + + + + + + + + + + + + diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.http b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.http new file mode 100644 index 0000000..dc8d74a --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.http @@ -0,0 +1,28 @@ +@SampleExtension.Radiology.Web.Ai_HostAddress = http://localhost:5080 + +### Liveness probe +GET {{SampleExtension.Radiology.Web.Ai_HostAddress}}/health/liveness + +### Readiness probe +GET {{SampleExtension.Radiology.Web.Ai_HostAddress}}/health/readiness + +### Process a radiology report (happy path) +# @timeout 600 +POST {{SampleExtension.Radiology.Web.Ai_HostAddress}}/v1/process +Content-Type: application/json + +{ + "extensibilityApiVersion": "1.1.1", + "sessionData": { + "correlation_id": "11111111-2222-3333-4444-555555555555", + "session_start": "2025-01-01T10:00:00Z", + "environment_id": "local-dev" + }, + "patientInformation": { + "dateOfBirth": "1980-05-12", + "biologicalSex": "Female" + }, + "report": { + "reportText": "CT ABDOMEN WITH CONTRAST: The liver demonstrates paddock steatosis. Chest X-ray performed with for views shows clear lung fields." + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/AzureOpenAIService.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/AzureOpenAIService.cs new file mode 100644 index 0000000..a32d766 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/AzureOpenAIService.cs @@ -0,0 +1,50 @@ +using Azure; +using Azure.AI.OpenAI; +using Microsoft.Extensions.Options; +using OpenAI.Chat; +using SampleExtension.Radiology.Web.Ai.Configuration; + +namespace SampleExtension.Radiology.Web.Ai.Services; + +/// +/// Azure OpenAI–backed chat completion provider. Reads and +/// builds a for the configured deployment. The client is created +/// once and reused for the lifetime of the service. +/// +public sealed class AzureOpenAIService : IAzureOpenAIService +{ + private readonly OpenAiSettings _settings; + private readonly Lazy _chatClient; + + public AzureOpenAIService(IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _settings = options.Value; + _chatClient = new Lazy(CreateChatClient); + } + + /// + public bool IsConfigured => + !string.IsNullOrWhiteSpace(_settings.Endpoint) + && !string.IsNullOrWhiteSpace(_settings.ApiKey) + && !string.IsNullOrWhiteSpace(_settings.DeploymentName); + + /// + public ChatClient GetChatClient() + { + if (!IsConfigured) + { + throw new InvalidOperationException( + "Azure OpenAI is not configured. Endpoint, ApiKey, and DeploymentName must all be set."); + } + + return _chatClient.Value; + } + + private ChatClient CreateChatClient() + { + var endpoint = new Uri(_settings.Endpoint); + var azureClient = new AzureOpenAIClient(endpoint, new AzureKeyCredential(_settings.ApiKey)); + return azureClient.GetChatClient(_settings.DeploymentName); + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/FoundryLocalService.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/FoundryLocalService.cs new file mode 100644 index 0000000..36f90f6 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/FoundryLocalService.cs @@ -0,0 +1,197 @@ +using System.ClientModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AI.Foundry.Local; +using Microsoft.Extensions.Options; +using OpenAI; +using OpenAI.Chat; +using SampleExtension.Radiology.Web.Ai.Configuration; +using FoundryConfiguration = Microsoft.AI.Foundry.Local.Configuration; + +namespace SampleExtension.Radiology.Web.Ai.Services; + +/// +/// On-device chat completion provider backed by Microsoft.AI.Foundry.Local. +/// +/// Lifecycle: +/// * Singleton in DI. +/// * Lazy initialization on first call (model download, +/// load, and local web service start can take seconds to minutes). +/// * Subsequent calls reuse the same loaded model and HTTP client. +/// * stops the web service and unloads the model. +/// +public sealed class FoundryLocalService : IFoundryLocalService, IAsyncDisposable +{ + private readonly FoundryLocalSettings _settings; + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + private readonly SemaphoreSlim _initLock = new(1, 1); + + private FoundryLocalManager? _manager; + private IModel? _model; + private ChatClient? _chatClient; + private bool _disposed; + + public FoundryLocalService( + IOptions settings, + ILogger logger, + ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(loggerFactory); + + _settings = settings.Value; + _logger = logger; + _loggerFactory = loggerFactory; + } + + /// + [SuppressMessage("Maintainability", "CA1508:Avoid dead conditional code", Justification = "Double-check locking; second null check is reachable from concurrent callers.")] + public async Task GetChatClientAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_chatClient is not null) + { + return _chatClient; + } + + await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_chatClient is not null) + { + return _chatClient; + } + + _chatClient = await InitializeAsync(cancellationToken).ConfigureAwait(false); + return _chatClient; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initializing Foundry Local."); + throw; + } + finally + { + _initLock.Release(); + } + } + + private async Task InitializeAsync(CancellationToken cancellationToken) + { + _logger.LogInformation( + "Initializing Foundry Local. Model={ModelAlias}, Device={DeviceType}, AppName={AppName}", + _settings.ModelAlias, + _settings.DeviceType, + _settings.AppName); + + var appDataDir = string.IsNullOrWhiteSpace(_settings.AppDataDir) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".foundry") + : _settings.AppDataDir; + + var configuration = new FoundryConfiguration + { + AppName = _settings.AppName, + AppDataDir = appDataDir, + // Bind to a random localhost port so multiple instances don't collide. + Web = new FoundryConfiguration.WebService { Urls = "http://127.0.0.1:0" } + }; + + await FoundryLocalManager.CreateAsync( + configuration, + _loggerFactory.CreateLogger()) + .ConfigureAwait(false); + + _manager = FoundryLocalManager.Instance; + + var deviceType = ParseDeviceType(_settings.DeviceType); + + var catalog = await _manager.GetCatalogAsync().ConfigureAwait(false); + var modelFamily = await catalog.GetModelAsync(_settings.ModelAlias).ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"Foundry Local model '{_settings.ModelAlias}' not found in catalog."); + + _model = modelFamily.Variants.FirstOrDefault(v => v.Info.Runtime?.DeviceType == deviceType) + ?? throw new InvalidOperationException( + $"No '{deviceType}' variant available for model '{_settings.ModelAlias}'."); + + if (!await _model.IsCachedAsync().ConfigureAwait(false)) + { + _logger.LogInformation( + "Foundry Local model '{ModelId}' is not cached locally. Downloading is a one-time operation that can take several minutes depending on model size and network speed. Subsequent runs will reuse the cached copy. Downloading now...", + _model.Id); + + await _model.DownloadAsync().ConfigureAwait(false); + + _logger.LogInformation("Foundry Local model '{ModelId}' download complete.", _model.Id); + } + else + { + _logger.LogInformation("Foundry Local model '{ModelId}' already cached locally; skipping download.", _model.Id); + } + + _logger.LogInformation("Loading model '{ModelId}' into memory (this may take a few seconds)...", _model.Id); + await _model.LoadAsync().ConfigureAwait(false); + + await _manager.StartWebServiceAsync().ConfigureAwait(false); + + var serviceUrl = _manager.Urls?.FirstOrDefault() + ?? throw new InvalidOperationException("Foundry Local web service started but reported no URL."); + + _logger.LogInformation( + "Foundry Local is ready. Model '{ModelId}' loaded; local OpenAI-compatible endpoint listening on {Url}.", + _model.Id, + serviceUrl); + + var openAiClient = new OpenAIClient( + new ApiKeyCredential("NO_API_KEY"), + new OpenAIClientOptions { Endpoint = new Uri($"{serviceUrl}/v1") }); + + return openAiClient.GetChatClient(_model.Id); + } + + private static DeviceType ParseDeviceType(string value) + { + if (Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + throw new InvalidOperationException( + $"Invalid Foundry Local DeviceType '{value}'. Expected one of: CPU, GPU, NPU."); + } + + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Disposal must never throw.")] + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + try + { + if (_manager is not null) + { + await _manager.StopWebServiceAsync().ConfigureAwait(false); + } + + if (_model is not null) + { + await _model.UnloadAsync().ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error while shutting down Foundry Local."); + } + finally + { + _manager?.Dispose(); + _initLock.Dispose(); + } + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IAzureOpenAIService.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IAzureOpenAIService.cs new file mode 100644 index 0000000..32d565e --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IAzureOpenAIService.cs @@ -0,0 +1,20 @@ +using OpenAI.Chat; + +namespace SampleExtension.Radiology.Web.Ai.Services; + +/// +/// Provides a chat client backed by an Azure OpenAI deployment when configured. +/// +public interface IAzureOpenAIService +{ + /// + /// True when Endpoint, ApiKey, and DeploymentName are all set in configuration. + /// + bool IsConfigured { get; } + + /// + /// Returns the chat client for the configured Azure OpenAI deployment. + /// Throws if is false. + /// + ChatClient GetChatClient(); +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IFoundryLocalService.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IFoundryLocalService.cs new file mode 100644 index 0000000..1da2d6b --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IFoundryLocalService.cs @@ -0,0 +1,18 @@ +using OpenAI.Chat; + +namespace SampleExtension.Radiology.Web.Ai.Services; + +/// +/// Provides on-device chat completion via Microsoft.AI.Foundry.Local. +/// Implementations lazily download/load the configured model on first use, start a local +/// OpenAI-compatible web service, and return a that targets it. +/// +public interface IFoundryLocalService +{ + /// + /// Returns a chat client backed by the local Foundry model. The first call performs + /// model download (if not cached), model load, and starts the local web service; + /// subsequent calls return the same already-initialized client. + /// + Task GetChatClientAsync(CancellationToken cancellationToken = default); +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IQualityCheckService.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IQualityCheckService.cs new file mode 100644 index 0000000..230e5bd --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IQualityCheckService.cs @@ -0,0 +1,13 @@ +using Dragon.Copilot.Radiology.Models; + +namespace SampleExtension.Radiology.Web.Ai.Services; + +/// +/// Abstraction for the component that turns an incoming radiology report +/// into a . In this sample it dispatches to +/// Azure OpenAI or a Foundry Local model. Replace with your own implementation. +/// +public interface IQualityCheckService +{ + ProcessResponse Process(ProcessRequest payload); +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/QualityCheckService.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/QualityCheckService.cs new file mode 100644 index 0000000..1f3a68c --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/QualityCheckService.cs @@ -0,0 +1,199 @@ +using System.Text.Json; +using Dragon.Copilot.Radiology.Models; +using Microsoft.Extensions.Options; +using OpenAI.Chat; +using SampleExtension.Radiology.Web.Ai.Configuration; + +namespace SampleExtension.Radiology.Web.Ai.Services; + +/// +/// Quality-check service that selects an inference provider in this priority order: +/// 1. Azure OpenAI (if endpoint, key, and deployment are configured) +/// 2. Foundry Local on-device model (if enabled) +/// If neither is available, throws . +/// +public sealed class QualityCheckService : IQualityCheckService +{ + /// + /// System prompt shared by all model-backed providers (Azure OpenAI and Foundry Local). + /// + private const string SystemPrompt = """ + + You are a highly accurate medical transcription and coding assistant for radiology. You review radiology reports produced by speech-to-text software and surface two kinds of recommendations: + + 1. Clinical issues - transcription errors, medical inaccuracies, or ambiguous wording that could affect patient care. Illustrative (non-anchoring) examples: + - Misheard or phonetically similar words (e.g., "paddock steatosis" vs. "hepatic steatosis") + - Incorrect numbers or dates (e.g., "for views" vs. "4 views", "Nine 20 5/24" vs. "9/25/24") + - Findings inconsistent with the patient's age or biological sex (e.g., prostate findings on a Female patient, pediatric only findings on an adult) + + 2. Billing issues - documentation gaps that affect accurate charge capture or CPT code selection. Illustrative examples: + - Missing or ambiguous laterality (left vs. right) on a procedure + - Missing contrast indication when the study title implies contrast use + - Missing view count on a radiograph (affects CPT selection, e.g., 71045-71048 for chest X-ray) + - Procedure performed but not clearly documented in the impression + + Input format (JSON): + { + "report": {"reportText": ""}, + "patientInformation": { + "dateOfBirth": "", + "biologicalSex": "" + } + } + + Output format (JSON) respond with a single JSON object matching this schema exactly: + { + "qualityCheckResult": { + "recommendations": [ + { + "qualityCheckType": "Clinical" | "Billing", + "description": "", + "reason": "", + "severityScorePercent": , + "provenance": [ + { + "text": "", + "startPosition": , + "endPosition": + } + ] + } + ] + } + } + + Field semantics: + - provenance.text MUST be an exact substring of reportText (same characters, same casing); provenance MUST contain at least one entry pointing to that span. + - startPosition is the 0-based character index of provenance.text in reportText; endPosition is end-exclusive, so reportText.substring(startPosition, endPosition) == provenance.text and endPosition - startPosition == text.Length. + - severityScorePercent rubric: 0-24 trivial / stylistic, 25-49 minor (no clinical or billing impact), 50-74 moderate (affects clarity or CPT code selection), 75-100 critical (affects patient care or correct charge capture). + + Quality rules: + - Only flag issues clearly supported by reportText and patientInformation; do not invent findings, codes, measurements, or terminology not present in the input. If uncertain, omit the recommendation. + - Do not duplicate recommendations; merge overlapping issues into a single entry. + + Response rules: + - Respond with a single JSON object. No prose, no commentary, no Markdown code fences. + - The top-level object MUST have exactly one key: "qualityCheckResult". + - If no issues are found, or if the input is not valid JSON in the expected shape, return exactly: {"qualityCheckResult": {"recommendations": []}} + """; + + private static readonly JsonSerializerOptions DeserializeOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly ILogger _logger; + private readonly IAzureOpenAIService _azureOpenAi; + private readonly IFoundryLocalService _foundryLocal; + private readonly FoundryLocalSettings _foundryLocalSettings; + + public QualityCheckService( + ILogger logger, + IAzureOpenAIService azureOpenAi, + IFoundryLocalService foundryLocal, + IOptions foundryLocalOptions) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(azureOpenAi); + ArgumentNullException.ThrowIfNull(foundryLocal); + ArgumentNullException.ThrowIfNull(foundryLocalOptions); + + _logger = logger; + _azureOpenAi = azureOpenAi; + _foundryLocal = foundryLocal; + _foundryLocalSettings = foundryLocalOptions.Value; + } + + /// + public ProcessResponse Process(ProcessRequest payload) + { + ArgumentNullException.ThrowIfNull(payload); + + _logger.LogInformation( + "Running quality check on radiology request. CorrelationId={CorrelationId}, ReportLength={ReportLength}", + payload.SessionData.CorrelationId, + payload.Report?.ReportText.Length); + + if (_azureOpenAi.IsConfigured) + { + _logger.LogInformation("Using Azure OpenAI provider."); + return ProcessWithChatClient(payload, _azureOpenAi.GetChatClient()); + } + + if (_foundryLocalSettings.Enabled) + { + _logger.LogInformation( + "Using Foundry Local provider (model={Model}). If this is the first request after startup, the model may need to download and load — this can take several minutes.", + _foundryLocalSettings.ModelAlias); + // GetChatClientAsync is lazily memoized; first call may be slow due to model download/load. + var chatClient = _foundryLocal.GetChatClientAsync().GetAwaiter().GetResult(); + return ProcessWithChatClient(payload, chatClient); + } + + throw new InvalidOperationException( + "No model provider configured. Set Azure OpenAI Endpoint/ApiKey/DeploymentName, or set FoundryLocal.Enabled=true in appsettings.json."); + } + + private ProcessResponse ProcessWithChatClient(ProcessRequest payload, ChatClient chatClient) + { + var prompt = JsonSerializer.Serialize(new + { + report = new { reportText = payload.Report?.ReportText }, + patientInformation = new + { + dateOfBirth = payload.PatientInformation?.DateOfBirth, + biologicalSex = payload.PatientInformation?.BiologicalSex.ToString() + } + }); + + var json = RunChatCompletion(chatClient, prompt); + return MapToResult(json); + } + + internal static string RunChatCompletion(ChatClient chatClient, string userMessage) + { + List messages = new List() + { + new SystemChatMessage(SystemPrompt), + new UserChatMessage(userMessage), + }; + + var response = chatClient.CompleteChat(messages, new ChatCompletionOptions()); + return response.Value.Content[0].Text; + } + + private ProcessResponse MapToResult(string json) + { + const string qualityCheckResultPropertyName = "qualityCheckResult"; + + _logger.LogDebug("Raw JSON response from the agent: {Json}", json); + + // Strip a surrounding Markdown code fence (e.g. ```json ... ```) that some chat models emit. + json = json.Trim(); + if (json.StartsWith("```", StringComparison.Ordinal)) + { + // Drop the opening fence line (handles ``` and ```json). + var firstNewline = json.IndexOf('\n', StringComparison.Ordinal); + json = firstNewline >= 0 ? json[(firstNewline + 1)..] : json[3..]; + if (json.EndsWith("```", StringComparison.Ordinal)) + { + json = json[..^3]; + } + + json = json.Trim(); + } + + var root = JsonDocument.Parse(json); + var qualityCheckResultElement = root.RootElement.GetProperty(qualityCheckResultPropertyName); + var qualityCheckResult = qualityCheckResultElement.Deserialize(DeserializeOptions); + + var response = new ProcessResponse + { + Success = true, + Message = "Payload processed successfully.", + Payload = new Dictionary(), + }; + response.Payload[qualityCheckResultPropertyName] = qualityCheckResult ?? new QualityCheckResult(); + return response; + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/appsettings.Development.json b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/appsettings.Development.json new file mode 100644 index 0000000..69e1624 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information", + "Microsoft.AspNetCore.Authentication": "Information", + "SampleExtension.Radiology": "Debug" + } + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/appsettings.json b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/appsettings.json new file mode 100644 index 0000000..372d7af --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/appsettings.json @@ -0,0 +1,33 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Authentication": { + "Enabled": false, + "TenantId": "", // Represents extension vendor's tenant id + "ClientId": "", // Represents extension vendor's client id + "Instance": "https://login.microsoftonline.com/", + "MapInboundClaims": false, + "AllowWebApiToBeAuthorizedByACL": true, + "RequiredClaims": { + "idtyp": [ "app" ], + "azp": [ "" ] // This represents Microsoft Dragon Copilot Extensions Runtime Application + } + }, + "OpenAI": { + "Endpoint": "", // Azure OpenAI resource endpoint URL + "ApiKey": "", // API key for authenticating with Azure OpenAI service + "DeploymentName": "" // Name of the deployed model in Azure OpenAI + }, + "FoundryLocal": { + "Enabled": true, // Used only when Azure OpenAI is not configured. When true, runs inference via Foundry Local on-device model. When false and Azure OpenAI is also not configured, the service throws at startup of the first request. + "ModelAlias": "qwen2.5-1.5b", // Alternatives: qwen2.5-0.5b, phi-3.5-mini, phi-4-mini, mistral-7b, gpt-oss-20b. + "DeviceType": "CPU", // CPU works on machines without GPU/NPU. Other values: GPU, NPU + "AppName": "DragonCopilotRadiologySample", + "AppDataDir": "" // Model cache + logs location. Empty = %USERPROFILE%\.foundry (shared). Set absolute path to override. + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/nuget.config b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/nuget.config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Configuration/AuthenticationOptions.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Configuration/AuthenticationOptions.cs new file mode 100644 index 0000000..740338e --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Configuration/AuthenticationOptions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace SampleExtension.Radiology.Web.Quickstart.Configuration; + +/// +/// JWT Authentication configuration options. +/// +public class AuthenticationOptions +{ + /// + /// The configuration section name. + /// + public const string SectionName = "Authentication"; + + /// + /// Whether authentication is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Tenant ID for the application. + /// + public string? TenantId { get; set; } + + /// + /// Client ID for the application. + /// + public string? ClientId { get; set; } + + /// + /// Login instance (e.g., "https://login.microsoftonline.com/"). + /// + public string Instance { get; set; } = "https://login.microsoftonline.com/"; + + /// + /// Required claims that must be present in JWT tokens. + /// + public IDictionary> RequiredClaims { get; } = new Dictionary>(); +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Controllers/QualityCheckController.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Controllers/QualityCheckController.cs new file mode 100644 index 0000000..cc49c4d --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Controllers/QualityCheckController.cs @@ -0,0 +1,70 @@ +using Dragon.Copilot.Radiology.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SampleExtension.Radiology.Web.Quickstart.Services; +using System.Text.Json; + +namespace SampleExtension.Radiology.Web.Quickstart.Controllers; + +/// +/// Single entry point of the Radiology simple extension. +/// Demonstrates a single-endpoint extension with model binding +/// performed by the framework and no authentication. +/// +[ApiController] +[Route("v1")] +[Produces("application/json")] +[Authorize(Policy = "RequiredClaims")] +public sealed class QualityCheckController : ControllerBase +{ + private readonly IQualityCheckService _qualityCheckService; + private readonly ILogger _logger; + + public QualityCheckController(IQualityCheckService qualityCheckService, ILogger logger) + { + ArgumentNullException.ThrowIfNull(qualityCheckService); + ArgumentNullException.ThrowIfNull(logger); + + _qualityCheckService = qualityCheckService; + _logger = logger; + } + + /// + /// Analyzes a radiology report and returns a list of quality-check recommendations. + /// + /// + /// This sample returns stubbed data loaded from MockData/qualitycheck-response.json. + /// Replace with your real implementation. + /// + [HttpPost("process")] + [ProducesResponseType(typeof(ProcessResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public ActionResult Post([FromBody] ProcessRequest payload) + { + ArgumentNullException.ThrowIfNull(payload); + + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + _logger.LogInformation( + "Received {Method} {Path} - CorrelationId={CorrelationId}", + Request.Method, + Request.Path, + payload.SessionData.CorrelationId); + + var result = _qualityCheckService.Process(payload); + + _logger.LogInformation( + "Response {Method} {Path} - Success: {Success} - Message: {Message} - Response Body: {ResponseBody}", + Request.Method, + Request.Path, + result.Success, + result.Message, + JsonSerializer.Serialize(result)); + + return Ok(result); + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Extensions/ServiceCollectionExtensions.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ca118d2 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Identity.Web; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using AuthOptions = SampleExtension.Radiology.Web.Quickstart.Configuration.AuthenticationOptions; + +namespace SampleExtension.Radiology.Web.Quickstart.Extensions; + +/// +/// Extension methods for service collection configuration. +/// +public static class ServiceCollectionExtensions +{ + private static readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + private static readonly ILogger _logger = _loggerFactory.CreateLogger(nameof(ServiceCollectionExtensions)); + + /// + /// Adds custom JWT authentication and claims-based authorization services. + /// + public static IServiceCollection AddCustomAuthentication(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + var authOptions = configuration.GetSection(AuthOptions.SectionName).Get(); + + // If authentication is disabled, add policies that always allow access + if (authOptions?.Enabled != true) + { + _logger.LogWarning("JWT authentication is disabled. All requests will be allowed without token validation."); + + services.AddAuthorization(options => + { + options.AddPolicy("RequiredClaims", policy => + policy.RequireAssertion(_ => true)); + }); + + return services; + } + + // Add JWT authentication + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(configuration.GetSection(AuthOptions.SectionName)); + + // Configure JWT Bearer events for diagnostics logging + services.Configure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.Events = new JwtBearerEvents + { + OnAuthenticationFailed = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + + if (context.Exception is SecurityTokenInvalidAudienceException audienceException) + { + var authHeader = context.Request.Headers["Authorization"].ToString(); + string? token = null; + + if (!string.IsNullOrEmpty(authHeader) && + AuthenticationHeaderValue.TryParse(authHeader, out var headerValue) && + string.Equals(headerValue.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase)) + { + token = headerValue.Parameter; + } + + if (!string.IsNullOrEmpty(token)) + { + try + { + var handler = new JsonWebTokenHandler(); + var jsonToken = handler.ReadJsonWebToken(token); + var actualAudience = jsonToken.GetClaim("aud")?.Value ?? "null"; + var expectedAudience = authOptions.ClientId ?? "null"; + + logger.LogWarning( + audienceException, + "JWT audience validation failed. Actual={ActualAudience}, Expected={ExpectedAudience}, Message={Message}", + actualAudience, + expectedAudience, + audienceException.Message); + } + catch (ArgumentException ex) + { + logger.LogWarning(ex, "Failed to parse JWT token for diagnostics: {Message}", ex.Message); + } + } + } + else + { + logger.LogWarning(context.Exception, "JWT authentication failed: {Message}", context.Exception.Message); + } + + return Task.CompletedTask; + }, + OnTokenValidated = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + var principal = context.Principal; + + if (principal?.Identity?.IsAuthenticated == true) + { + logger.LogDebug("JWT token validated successfully."); + + if (authOptions.RequiredClaims.Count != 0) + { + foreach (var requiredClaim in authOptions.RequiredClaims) + { + var actualValues = principal.Claims + .Where(c => c.Type == requiredClaim.Key) + .Select(c => c.Value); + logger.LogDebug( + "Claim validation: {ClaimType} expected=[{Expected}] actual=[{Actual}]", + requiredClaim.Key, + string.Join(", ", requiredClaim.Value), + string.Join(", ", actualValues)); + } + } + } + + return Task.CompletedTask; + }, + }; + }); + + // Add authorization with custom policies + services.AddAuthorization(options => + { + options.AddPolicy("RequiredClaims", policy => + { + policy.RequireAuthenticatedUser(); + + if (authOptions.RequiredClaims.Count != 0) + { + foreach (var claim in authOptions.RequiredClaims) + { + policy.RequireClaim(claim.Key, claim.Value); + } + } + }); + }); + + return services; + } + + /// + /// Registers configuration option classes for authentication. + /// + public static IServiceCollection AddSecurityOptions(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + services.Configure(configuration.GetSection(AuthOptions.SectionName)); + + return services; + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Extensions/WebApplicationExtensions.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Extensions/WebApplicationExtensions.cs new file mode 100644 index 0000000..9385e28 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace SampleExtension.Radiology.Web.Quickstart.Extensions; + +/// +/// Extension methods for configuring the web application security pipeline. +/// +internal static class WebApplicationExtensions +{ + private static readonly string[] PublicRoutes = ["/health", "/v1/health", "/index.html"]; + + /// + /// Applies JWT authentication and authorization to all non-public routes. + /// + internal static WebApplication UseFullSecurity(this WebApplication app) + { + app.UseWhen( + context => !PublicRoutes.Any(r => (context.Request.Path.Value ?? string.Empty).StartsWith(r, StringComparison.OrdinalIgnoreCase)), + protectedBranch => + { + protectedBranch.UseAuthentication(); + protectedBranch.UseAuthorization(); + }); + + return app; + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/MockData/qualitycheck-response.json b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/MockData/qualitycheck-response.json new file mode 100644 index 0000000..4282b43 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/MockData/qualitycheck-response.json @@ -0,0 +1,49 @@ +{ + "success": true, + "message": "Payload processed successfully.", + "payload": { + "qualityCheckResult": { + "recommendations": [ + { + "qualityCheckType": "Clinical", + "description": "Replace 'paddock steatosis' with 'hepatic steatosis'.", + "reason": "'Paddock steatosis' is not a recognized medical term and is a well-known speech-to-text mis-hearing of 'hepatic steatosis' (fatty liver). Leaving the erroneous term in the final report can mislead downstream clinicians, omit a clinically significant finding from the patient's problem list, and break automated coding and decision-support tools that key off standard terminology.", + "severityScorePercent": 85, + "provenance": [ + { + "text": "paddock steatosis", + "startPosition": 42, + "endPosition": 59 + } + ] + }, + { + "qualityCheckType": "Clinical", + "description": "Replace 'for views' with '4 views' on the chest radiograph.", + "reason": "'For views' is a phonetic mis-hearing of '4 views'. The number of projections obtained is part of the radiographic technique and must be documented accurately so the interpreting radiologist and downstream clinicians know the exam was a complete 4-view chest series rather than a limited study. An incorrect view count can change how findings are weighed and may lead to unnecessary repeat imaging.", + "severityScorePercent": 50, + "provenance": [ + { + "text": "for views", + "startPosition": 120, + "endPosition": 129 + } + ] + }, + { + "qualityCheckType": "Billing", + "description": "Document whether IV contrast was actually administered for the CT abdomen study.", + "reason": "The study is titled 'CT ABDOMEN WITH CONTRAST', but the body of the report does not explicitly state that intravenous contrast was administered, the contrast agent and volume used, or any pre-scan renal function or allergy screening. Accurate CPT selection depends on this distinction: 74150 (CT abdomen without contrast), 74160 (with contrast), and 74170 (without and with contrast). Missing documentation can lead to downcoding, payer denials, or compliance findings on audit. Confirm contrast administration with the technologist and update the technique section before finalizing the report.", + "severityScorePercent": 65, + "provenance": [ + { + "text": "CT ABDOMEN WITH CONTRAST", + "startPosition": 0, + "endPosition": 24 + } + ] + } + ] + } + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Program.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Program.cs new file mode 100644 index 0000000..a4a3d67 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Program.cs @@ -0,0 +1,69 @@ +// Minimal, self-contained Radiology extension sample. +// Partners can copy this project folder and run it with `dotnet run`. + +using SampleExtension.Radiology.Web.Quickstart.Extensions; +using SampleExtension.Radiology.Web.Quickstart.Services; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +// Services +builder.Services.AddSingleton(); + +// JWT authentication (Microsoft Entra ID). +// Toggle on/off via the "Authentication" config section. +builder.Services.AddCustomAuthentication(builder.Configuration); +builder.Services.AddSecurityOptions(builder.Configuration); + +builder.Services + .AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + }); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo + { + Title = "Simple Radiology Extension API", + Version = "v1", + Description = "A simple radiology extension sample that demonstrates the extension pattern for Dragon Copilot." + }); +}); +builder.Services.AddHealthChecks(); + +// CORS is fully open here for easy local testing. Make sure to restrict this for production. +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => policy + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader()); +}); + +var app = builder.Build(); + +app.UseCors(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Simple Radiology Extension API v1"); + c.RoutePrefix = string.Empty; // Serve Swagger UI at the app's root. + }); +} + +app.MapHealthChecks("/health/liveness"); +app.MapHealthChecks("/health/readiness"); + +// Apply JWT authentication to all non-public routes +app.UseFullSecurity(); + +app.MapControllers(); + +app.Run(); diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Properties/launchSettings.json b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Properties/launchSettings.json new file mode 100644 index 0000000..7ecfe2c --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7080;http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/README.md b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/README.md new file mode 100644 index 0000000..43defaf --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/README.md @@ -0,0 +1,146 @@ +# Partner Extension Sample — Quickstart + +A minimal radiology extension for Dragon Copilot. It returns a canned +quality-check response loaded from a JSON file on disk. Use it as the +starting point for a new partner extension, replace the stubbed logic with +your own implementation and deploy a working extension that follows the expected contract. + +## What's included + +- ASP.NET Core Web API (.NET 10), single controller: `POST /v1/process` +- JWT authentication via Microsoft Entra ID, toggleable via `Authentication.Enabled` in `appsettings.json` +- Stubbed responses loaded from JSON files under `MockData/` +- Swagger UI at the app root in Development +- Health probes at `/health/liveness` and `/health/readiness` + +## Run locally + +```powershell +dotnet run --project SampleExtension.Radiology.Web.Quickstart +``` + +Available endpoints: + +- Swagger UI: http://localhost:5080/ +- Health: `/health/liveness`, `/health/readiness` + +A `.http` file (`SampleExtension.Radiology.Web.Quickstart.http`) is included +for sending sample requests from Visual Studio or VS Code. + +## Security + +The application validates JWT bearer tokens on all routes except health probes. +Authentication is configurable via `appsettings.json`. + +### JWT Authentication (Microsoft Entra ID) + +Validates that every incoming request carries a valid Bearer token issued by +the configured Entra ID tenant. Requests with a missing, expired, or invalid +token receive a **401 Unauthorized** response. + +Configure in the `Authentication` section of `appsettings.json`: + +| Setting | Description | +| ---------------- | --------------------------------- | +| `Enabled` | Enable or disable authentication | +| `TenantId` | Your Entra ID tenant ID | +| `ClientId` | Your app registration's client ID | +| `Instance` | Login endpoint | +| `RequiredClaims` | Additional claims to enforce | + +### Local development + +When you first clone or download the repository, authentication is disabled by +default so the API can be called without tokens. + +See [`appsettings.json`](./appsettings.json) for the full schema, defaults, +and inline comments describing each setting. + +### Enabling security for production + +To enable security: + +1. Register an application in [Microsoft Entra ID](https://entra.microsoft.com/). +2. Set `Authentication.Enabled` to `true` and populate `TenantId`, `ClientId`, + and allowed caller client IDs in `RequiredClaims.azp`. + +Example with security enabled: + +```jsonc +{ + "Authentication": { + "Enabled": true, + "TenantId": "00000000-0000-0000-0000-000000000000", + "ClientId": "11111111-1111-1111-1111-111111111111", + "Instance": "https://login.microsoftonline.com/", + "MapInboundClaims": false, + "AllowWebApiToBeAuthorizedByACL": true, + "RequiredClaims": { + "idtyp": ["app"], + "azp": [ + "22222222-2222-2222-2222-222222222222", + "33333333-3333-3333-3333-333333333333", + ], + }, + }, +} +``` + +Once enabled, callers must include the bearer token on every request: + +``` +Authorization: Bearer +``` + +## Quality check provider + +This Quickstart sample always returns the canned response in +[`MockData/qualitycheck-response.json`](./MockData/qualitycheck-response.json). +Partners can edit the JSON directly to tweak the stubbed output without +modifying any C# code. + +To replace the stub with real logic, edit +[`Services/QualityCheckService.cs`](./Services/QualityCheckService.cs) — the +`IQualityCheckService.Process` method is the single integration point. + +## Request / response contract + +See [`radiology-extensibility-api.yaml`](../../../../radiology-extensibility-api.yaml) for the full OpenAPI spec. + +Only `sessionData` is required. `extensibilityApiVersion` shows which Dragon Copilot API version sent the request, and your extension does not need to read it. Extra fields are accepted, so your extension keeps working as the API evolves. + +**`POST /v1/process`** with `application/json` + +```jsonc +{ + "extensibilityApiVersion": "1.1.1", + "sessionData": { + "correlation_id": "abc-123", + "session_start": "2025-01-01T10:00:00Z", + "environment_id": "env-456", + }, + "patientInformation": { + "dateOfBirth": "1980-05-12", + "biologicalSex": "Female", + }, + "report": { + "reportText": "CT ABDOMEN … paddock steatosis …", + }, +} +``` + +Response: + +```jsonc +{ + "success": true, + "message": "Payload processed successfully.", + "payload": { + "qualityCheckResult": { + "recommendations": [ + /* ... */ + ], + }, + }, +} +``` diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/SampleExtension.Radiology.Web.Quickstart.csproj b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/SampleExtension.Radiology.Web.Quickstart.csproj new file mode 100644 index 0000000..5b727ba --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/SampleExtension.Radiology.Web.Quickstart.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + SampleExtension.Radiology.Web.Quickstart + SampleExtension.Radiology.Web.Quickstart + true + + CA1812;CA1515;CA1848;CA1873;CS1591;CA2227 + + + + + + + + + + + + + + + + + + + diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/SampleExtension.Radiology.Web.Quickstart.http b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/SampleExtension.Radiology.Web.Quickstart.http new file mode 100644 index 0000000..8fbdd0f --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/SampleExtension.Radiology.Web.Quickstart.http @@ -0,0 +1,28 @@ +@SampleExtension.Radiology.Web.Quickstart_HostAddress = http://localhost:5080 + +### Liveness probe +GET {{SampleExtension.Radiology.Web.Quickstart_HostAddress}}/health/liveness + +### Readiness probe +GET {{SampleExtension.Radiology.Web.Quickstart_HostAddress}}/health/readiness + +### Process a radiology report (happy path) +# @timeout 60 +POST {{SampleExtension.Radiology.Web.Quickstart_HostAddress}}/v1/process +Content-Type: application/json + +{ + "extensibilityApiVersion": "1.1.1", + "sessionData": { + "correlation_id": "11111111-2222-3333-4444-555555555555", + "session_start": "2025-01-01T10:00:00Z", + "environment_id": "local-dev" + }, + "patientInformation": { + "dateOfBirth": "1980-05-12", + "biologicalSex": "Female" + }, + "report": { + "reportText": "CT ABDOMEN WITH CONTRAST: The liver demonstrates paddock steatosis. Chest X-ray performed with for views shows clear lung fields." + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/IQualityCheckService.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/IQualityCheckService.cs new file mode 100644 index 0000000..3c8f2f5 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/IQualityCheckService.cs @@ -0,0 +1,13 @@ +using Dragon.Copilot.Radiology.Models; + +namespace SampleExtension.Radiology.Web.Quickstart.Services; + +/// +/// Abstraction for the component that turns an incoming radiology report +/// into a . In this sample it returns canned +/// data loaded from disk. Replace with your own implementation. +/// +public interface IQualityCheckService +{ + ProcessResponse Process(ProcessRequest payload); +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/QualityCheckService.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/QualityCheckService.cs new file mode 100644 index 0000000..4404482 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/QualityCheckService.cs @@ -0,0 +1,101 @@ +using Dragon.Copilot.Radiology.Models; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SampleExtension.Radiology.Web.Quickstart.Services; + +/// +/// Quality-check service that returns a stubbed response loaded from +/// MockData/qualitycheck-response.json. Partners can replace the +/// implementation with their own logic. +/// +public sealed class QualityCheckService : IQualityCheckService +{ + private const string QualityCheckPayloadKey = "qualityCheckResult"; + private static readonly JsonSerializerOptions DeserializeOptions = new() + { + PropertyNameCaseInsensitive = true, + PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, + }; + + private readonly ILogger _logger; + private readonly Lazy _mockResponse; + + public QualityCheckService( + IConfiguration configuration, + IWebHostEnvironment env, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(env); + ArgumentNullException.ThrowIfNull(logger); + + _logger = logger; + + var relativePath = configuration["SampleExtension:MockDataFile"] ?? "MockData/qualitycheck-response.json"; + var fullPath = Path.Combine(env.ContentRootPath, relativePath); + + _mockResponse = new Lazy(() => LoadMockResponse(fullPath)); + } + + /// + public ProcessResponse Process(ProcessRequest payload) + { + ArgumentNullException.ThrowIfNull(payload); + + _logger.LogInformation( + "Running quality check on radiology request. CorrelationId={CorrelationId}, ReportLength={ReportLength}", + payload.SessionData.CorrelationId, + payload.Report?.ReportText.Length); + + _logger.LogInformation("No model provider configured. Returning mock data."); + return ProcessWithMockData(); + } + + private ProcessResponse ProcessWithMockData() + { + var template = _mockResponse.Value; + var result = new ProcessResponse + { + Success = template.Success, + Message = template.Message, + Payload = new Dictionary(), + }; + + if (template.Payload is { } templatePayload + && templatePayload.TryGetValue(QualityCheckPayloadKey, out var templateQc)) + { + result.Payload[QualityCheckPayloadKey] = new QualityCheckResult + { + Recommendations = [.. templateQc.Recommendations], + }; + } + + return result; + } + + private ProcessResponse LoadMockResponse(string fullPath) + { + if (!File.Exists(fullPath)) + { + _logger.LogWarning("Mock data file not found at {Path}. Returning an empty successful response.", fullPath); + return new ProcessResponse { Success = true, Message = "No mock data configured." }; + } + + var json = File.ReadAllText(fullPath); + var response = JsonSerializer.Deserialize(json, DeserializeOptions); + + if (response is null) + { + _logger.LogWarning("Mock data file at {Path} deserialized to null. Returning an empty successful response.", fullPath); + return new ProcessResponse { Success = true, Message = "Mock data was empty." }; + } + + _logger.LogInformation( + "Loaded {Count} mock recommendation(s) from {Path}.", + response.Payload?[QualityCheckPayloadKey].Recommendations.Count ?? 0, + fullPath); + + return response; + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/appsettings.Development.json b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/appsettings.Development.json new file mode 100644 index 0000000..69e1624 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information", + "Microsoft.AspNetCore.Authentication": "Information", + "SampleExtension.Radiology": "Debug" + } + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/appsettings.json b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/appsettings.json new file mode 100644 index 0000000..9ac12c6 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/appsettings.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "SampleExtension": { + "MockDataFile": "MockData/qualitycheck-response.json" + }, + "Authentication": { + "Enabled": false, + "TenantId": "", // Represents extension vendor's tenant id + "ClientId": "", // Represents extension vendor's client id + "Instance": "https://login.microsoftonline.com/", + "MapInboundClaims": false, + "AllowWebApiToBeAuthorizedByACL": true, + "RequiredClaims": { + "idtyp": [ "app" ], + "azp": [ "" ] // This represents Microsoft Dragon Copilot Extensions Runtime Application + } + } +} diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/nuget.config b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/nuget.config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/radiology/src/samples/Workflow/SampleExtensions.Radiology.Web.slnx b/radiology/src/samples/Workflow/SampleExtensions.Radiology.Web.slnx new file mode 100644 index 0000000..8ffb671 --- /dev/null +++ b/radiology/src/samples/Workflow/SampleExtensions.Radiology.Web.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/radiology/src/samples/requests/FullRequest-Example.json b/radiology/src/samples/requests/FullRequest-Example.json index 02b0094..7d1bc1d 100644 --- a/radiology/src/samples/requests/FullRequest-Example.json +++ b/radiology/src/samples/requests/FullRequest-Example.json @@ -1,11 +1,11 @@ { - "schemaVersion": "0.0", + "extensibilityApiVersion": "1.1.1", "sessionData": { "correlation_id": "f40dd0d0-4201-d29d-79b4-df40189fd2f0", "session_start": "2025-06-20T08:57:35.978Z", "environment_id": "01bd0d47-1621-4a29-941d-00e9a9420f20" }, - "patientInfo": { + "patientInformation": { "dateOfBirth": "1990-01-15", "biologicalSex": "Male" }, diff --git a/radiology/src/samples/requests/PatientInfoRequest-Example.json b/radiology/src/samples/requests/PatientInformationRequest-Example.json similarity index 80% rename from radiology/src/samples/requests/PatientInfoRequest-Example.json rename to radiology/src/samples/requests/PatientInformationRequest-Example.json index a68b997..4b59f64 100644 --- a/radiology/src/samples/requests/PatientInfoRequest-Example.json +++ b/radiology/src/samples/requests/PatientInformationRequest-Example.json @@ -1,11 +1,11 @@ { - "schemaVersion": "0.0", + "extensibilityApiVersion": "1.1.1", "sessionData": { "correlation_id": "f40dd0d0-4201-d29d-79b4-df40189fd2f0", "session_start": "2025-06-20T08:57:35.978Z", "environment_id": "01bd0d47-1621-4a29-941d-00e9a9420f20" }, - "patientInfo": { + "patientInformation": { "dateOfBirth": "1990-01-15", "biologicalSex": "Male" } diff --git a/radiology/src/samples/requests/QualityCheckResultResponse-Example.json b/radiology/src/samples/requests/QualityCheckResultResponse-Example.json index e3a07ff..c18beb2 100644 --- a/radiology/src/samples/requests/QualityCheckResultResponse-Example.json +++ b/radiology/src/samples/requests/QualityCheckResultResponse-Example.json @@ -1,5 +1,4 @@ { - "schemaVersion": "0.0", "success": true, "message": "Quality check completed successfully.", "payload": { diff --git a/radiology/src/samples/requests/ReportRequest-Example.json b/radiology/src/samples/requests/ReportRequest-Example.json index 81114cd..dc2ebed 100644 --- a/radiology/src/samples/requests/ReportRequest-Example.json +++ b/radiology/src/samples/requests/ReportRequest-Example.json @@ -1,5 +1,5 @@ { - "schemaVersion": "0.0", + "extensibilityApiVersion": "1.1.1", "sessionData": { "correlation_id": "f40dd0d0-4201-d29d-79b4-df40189fd2f0", "session_start": "2025-06-20T08:57:35.978Z", diff --git a/radiology/src/samples/requests/sample-requests-responses.md b/radiology/src/samples/requests/sample-requests-responses.md index 8b79cd1..6a0e320 100644 --- a/radiology/src/samples/requests/sample-requests-responses.md +++ b/radiology/src/samples/requests/sample-requests-responses.md @@ -4,16 +4,17 @@ This directory contains sample request and response payloads for a Dragon Copilo ## Sample Request: Patient Information -File: [PatientInfoRequest-Example.json](./PatientInfoRequest-Example.json) +File: [PatientInformationRequest-Example.json](./PatientInformationRequest-Example.json) -This file contains a sample request payload for an extension that is configured to handle an input of content-type `application/vnd.ms-dragon.rad.patient-info+json`. The name of the parameter is `patientInfo`, which is defined in the extension's manifest. +This file contains a sample request payload for an extension that is configured to handle an input of content-type `application/vnd.ms-dragon.rad.patient-information+json`. The name of the parameter is `patientInformation`, which is defined in the extension's manifest. Sample Manifest Configuration: ```yaml inputs: - - name: patientInfo + - name: patientInformation description: Patient demographic information from Dragon Copilot - content-type: application/vnd.ms-dragon.rad.patient-info+json + content-type: application/vnd.ms-dragon.rad.patient-information+json + schemaVersion: "1.0" ``` ## Sample Request: Report Payload @@ -28,13 +29,14 @@ Sample Manifest Configuration: - name: report description: Radiology report from Dragon Copilot content-type: application/vnd.ms-dragon.rad.report+json + schemaVersion: "1.0" ``` ## Sample Request: Combined (Patient Information + Report) File: [FullRequest-Example.json](./FullRequest-Example.json) -This file contains a sample request payload demonstrating both `patientInfo` and `report` inputs sent together in a single `/v1/process` request. +This file contains a sample request payload demonstrating both `patientInformation` and `report` inputs sent together in a single `/v1/process` request. ## Sample Response: Quality Check Result @@ -48,4 +50,5 @@ Sample Manifest Configuration: - name: qualityCheckResult description: Quality check findings and score content-type: application/vnd.ms-dragon.rad.quality-check-result+json + schemaVersion: "1.0" ``` diff --git a/tools/dragon-copilot-cli/src/__tests__/cli-integration.test.ts b/tools/dragon-copilot-cli/src/__tests__/cli-integration.test.ts index fae9ac0..0d3afe0 100644 --- a/tools/dragon-copilot-cli/src/__tests__/cli-integration.test.ts +++ b/tools/dragon-copilot-cli/src/__tests__/cli-integration.test.ts @@ -26,13 +26,14 @@ const EXTENSION_MANIFEST = [ ].join('\n'); const RADIOLOGY_EXTENSION_MANIFEST = [ - 'name: integration-radiology-extension', + 'name: integrationRadiologyExtension', 'description: Radiology extension manifest used in integration tests', 'version: 0.0.1', + 'radiologyExtensibilityApiVersion: 1.0.0', 'auth:', ' tenantId: 00000000-0000-0000-0000-000000000001', 'tools:', - ' - name: quality-checker', + ' - name: qualityChecker', ' toolType: contractBased', ' capability: qualityCheck', ' description: Checks the quality of a radiology report', @@ -41,13 +42,16 @@ const RADIOLOGY_EXTENSION_MANIFEST = [ ' - name: report', ' description: Radiology report from Dragon Copilot', ' content-type: application/vnd.ms-dragon.rad.report+json', - ' - name: patient-info', + ' schemaVersion: "1.0"', + ' - name: patientInformation', ' description: Patient demographic information', - ' content-type: application/vnd.ms-dragon.rad.patient-info+json', + ' content-type: application/vnd.ms-dragon.rad.patient-information+json', + ' schemaVersion: "1.0"', ' outputs:', - ' - name: quality-check-result', + ' - name: qualityCheckResult', ' description: Quality check findings and score', ' content-type: application/vnd.ms-dragon.rad.quality-check-result+json', + ' schemaVersion: "1.0"', ].join('\n'); const PARTNER_MANIFEST = [ diff --git a/tools/dragon-copilot-cli/src/__tests__/manifest-validation.test.ts b/tools/dragon-copilot-cli/src/__tests__/manifest-validation.test.ts index d70f757..38fa5ae 100644 --- a/tools/dragon-copilot-cli/src/__tests__/manifest-validation.test.ts +++ b/tools/dragon-copilot-cli/src/__tests__/manifest-validation.test.ts @@ -285,15 +285,16 @@ describe('validateConnectorManifest', () => { describe('validateDcrExtensionManifest (radiology)', () => { function buildValidRadiologyManifest(): DcrExtensionManifest { return { - name: 'test-radiology-extension', + name: 'testRadiologyExtension', description: 'Radiology extension for schema validation tests', version: '1.0.0', + radiologyExtensibilityApiVersion: '1.0.0', auth: { tenantId: TENANT_ID, }, tools: [ { - name: 'quality-checker', + name: 'qualityChecker', toolType: 'contractBased', capability: 'qualityCheck', description: 'Checks radiology report quality', @@ -303,18 +304,21 @@ describe('validateDcrExtensionManifest (radiology)', () => { name: 'report', description: 'Radiology report', 'content-type': 'application/vnd.ms-dragon.rad.report+json', + schemaVersion: '1.0', }, { - name: 'patient-info', + name: 'patientInformation', description: 'Patient demographic information', - 'content-type': 'application/vnd.ms-dragon.rad.patient-info+json', + 'content-type': 'application/vnd.ms-dragon.rad.patient-information+json', + schemaVersion: '1.0', }, ], outputs: [ { - name: 'quality-check-result', + name: 'qualityCheckResult', description: 'Quality check findings', 'content-type': 'application/vnd.ms-dragon.rad.quality-check-result+json', + schemaVersion: '1.0', }, ], }, @@ -383,5 +387,123 @@ describe('validateDcrExtensionManifest (radiology)', () => { expect(result.isValid).toBe(false); expect(result.errors.some((e: SchemaError) => e.keyword === 'additionalProperties')).toBe(true); }); + + it('rejects manifest missing top-level radiologyExtensibilityApiVersion', () => { + const manifest = buildValidRadiologyManifest(); + delete (manifest as any).radiologyExtensibilityApiVersion; + + const result = validateDcrExtensionManifest(manifest); + + expect(result.isValid).toBe(false); + expect( + result.errors.some( + (e: SchemaError) => + e.keyword === 'required' && e.params?.missingProperty === 'radiologyExtensibilityApiVersion', + ), + ).toBe(true); + }); + + it('rejects radiologyExtensibilityApiVersion in wrong format', () => { + const manifest = buildValidRadiologyManifest(); + manifest.radiologyExtensibilityApiVersion = '1.0'; + + const result = validateDcrExtensionManifest(manifest); + + expect(result.isValid).toBe(false); + expect( + result.errors.some((e: SchemaError) => + e.instancePath?.includes('radiologyExtensibilityApiVersion'), + ), + ).toBe(true); + }); + + it('rejects input missing schemaVersion', () => { + const manifest = buildValidRadiologyManifest(); + delete (manifest.tools[0].inputs[0] as any).schemaVersion; + + const result = validateDcrExtensionManifest(manifest); + + expect(result.isValid).toBe(false); + expect( + result.errors.some( + (e: SchemaError) => + e.keyword === 'required' && e.params?.missingProperty === 'schemaVersion', + ), + ).toBe(true); + }); + + it('rejects output missing schemaVersion', () => { + const manifest = buildValidRadiologyManifest(); + delete (manifest.tools[0].outputs[0] as any).schemaVersion; + + const result = validateDcrExtensionManifest(manifest); + + expect(result.isValid).toBe(false); + expect( + result.errors.some( + (e: SchemaError) => + e.keyword === 'required' && e.params?.missingProperty === 'schemaVersion', + ), + ).toBe(true); + }); + + it('rejects schemaVersion in wrong format (three segments)', () => { + const manifest = buildValidRadiologyManifest(); + manifest.tools[0].inputs[0].schemaVersion = '1.0.0'; + + const result = validateDcrExtensionManifest(manifest); + + expect(result.isValid).toBe(false); + expect( + result.errors.some((e: SchemaError) => + e.instancePath?.includes('schemaVersion'), + ), + ).toBe(true); + }); + + it('rejects extension name in kebab-case (camelCase required)', () => { + const manifest = buildValidRadiologyManifest(); + manifest.name = 'not-camel-case'; + + const result = validateDcrExtensionManifest(manifest); + + expect(result.isValid).toBe(false); + expect( + result.errors.some( + (e: SchemaError) => + e.keyword === 'pattern' && e.instancePath === '/name', + ), + ).toBe(true); + }); + + it('rejects tool name in kebab-case (camelCase required)', () => { + const manifest = buildValidRadiologyManifest(); + manifest.tools[0].name = 'quality-checker'; + + const result = validateDcrExtensionManifest(manifest); + + expect(result.isValid).toBe(false); + expect( + result.errors.some( + (e: SchemaError) => + e.keyword === 'pattern' && e.instancePath?.includes('/tools/0/name'), + ), + ).toBe(true); + }); + + it('rejects an invalid input content-type', () => { + const manifest = buildValidRadiologyManifest(); + (manifest.tools[0].inputs[0] as any)['content-type'] = + 'application/vnd.ms-dragon.rad.unknown+json'; + + const result = validateDcrExtensionManifest(manifest); + + expect(result.isValid).toBe(false); + expect( + result.errors.some((e: SchemaError) => + e.instancePath?.includes('content-type'), + ), + ).toBe(true); + }); }); diff --git a/tools/dragon-copilot-cli/src/domains/radiology/commands/generate.ts b/tools/dragon-copilot-cli/src/domains/radiology/commands/generate.ts index 1a47f0c..ce581ab 100644 --- a/tools/dragon-copilot-cli/src/domains/radiology/commands/generate.ts +++ b/tools/dragon-copilot-cli/src/domains/radiology/commands/generate.ts @@ -50,7 +50,8 @@ async function generateInteractive(options: GenerateOptions): Promise { inputs: answers.inputTypes.map((contentType: string, index: number) => ({ name: getInputName(contentType, index), description: getInputDescription(contentType), - 'content-type': contentType + 'content-type': contentType, + schemaVersion: '1.0' })), outputs: answers.outputs }; @@ -69,9 +70,10 @@ async function generateInteractive(options: GenerateOptions): Promise { const authDetails = await promptAuthDetails(); manifest = { - name: 'my-radiology-extension', + name: 'myRadiologyExtension', description: 'A Dragon Copilot radiology extension', version: '0.0.1', + radiologyExtensibilityApiVersion: '1.0.0', auth: { tenantId: authDetails.tenantId }, @@ -103,6 +105,7 @@ async function generateFromTemplate(options: GenerateOptions): Promise { name: template.name, description: template.description, version: template.version, + radiologyExtensibilityApiVersion: template.radiologyExtensibilityApiVersion, auth: { tenantId: authDetails.tenantId }, diff --git a/tools/dragon-copilot-cli/src/domains/radiology/commands/init.ts b/tools/dragon-copilot-cli/src/domains/radiology/commands/init.ts index b8ff983..ecf01df 100644 --- a/tools/dragon-copilot-cli/src/domains/radiology/commands/init.ts +++ b/tools/dragon-copilot-cli/src/domains/radiology/commands/init.ts @@ -51,6 +51,7 @@ export async function initProject(options: InitOptions): Promise { name: extensionDetails.name, description: extensionDetails.description, version: extensionDetails.version, + radiologyExtensibilityApiVersion: extensionDetails.radiologyExtensibilityApiVersion, auth: { tenantId: authDetails.tenantId }, @@ -61,7 +62,7 @@ export async function initProject(options: InitOptions): Promise { const toolDetails = await promptToolDetails(undefined, { allowMultipleInputs: true, defaults: { - toolName: 'my-radiology-tool', + toolName: 'myRadiologyTool', toolDescription: 'Processes radiology reports and imaging data', endpoint: 'https://api.example.com/radiology/v1/process' } @@ -76,7 +77,8 @@ export async function initProject(options: InitOptions): Promise { inputs: toolDetails.inputTypes.map((contentType, index) => ({ name: getInputName(contentType, index), description: getInputDescription(contentType), - 'content-type': contentType + 'content-type': contentType, + schemaVersion: '1.0' })), outputs: toolDetails.outputs }; diff --git a/tools/dragon-copilot-cli/src/domains/radiology/shared/prompts.ts b/tools/dragon-copilot-cli/src/domains/radiology/shared/prompts.ts index b66bc74..2486f47 100644 --- a/tools/dragon-copilot-cli/src/domains/radiology/shared/prompts.ts +++ b/tools/dragon-copilot-cli/src/domains/radiology/shared/prompts.ts @@ -10,6 +10,7 @@ export interface ExtensionDetails { name: string; description: string; version: string; + radiologyExtensibilityApiVersion: string; } export interface ToolDetails { @@ -25,7 +26,7 @@ export interface ToolDetails { export const INPUT_TYPE_CHOICES = [ { name: 'Radiology Report', value: 'application/vnd.ms-dragon.rad.report+json' }, - { name: 'Patient Info', value: 'application/vnd.ms-dragon.rad.patient-info+json' }, + { name: 'Patient Information', value: 'application/vnd.ms-dragon.rad.patient-information+json' }, ]; export const TOOL_TYPE_CHOICES = [ @@ -41,8 +42,8 @@ export const CAPABILITY_CHOICES = [ */ export function validateToolName(input: string, existingManifest?: DcrExtensionManifest | null): string | boolean { if (!input.trim()) return 'Tool name is required'; - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(input)) { - return 'Tool name must use lowercase kebab-case segments (letters/numbers separated by single hyphens)'; + if (!/^[a-z][a-zA-Z0-9]*$/.test(input)) { + return 'Tool name must use camelCase (start with a lowercase letter, followed by letters and numbers)'; } if (existingManifest?.tools.find(t => t.name === input)) { return 'Tool with this name already exists'; @@ -77,6 +78,13 @@ export function validateVersion(input: string): string | boolean { return validateFieldValue(input, 'version', 'manifest'); } +/** + * Validates the Radiology Extensibility API version (x.y.z) the manifest was authored against. + */ +export function validateRadiologyExtensibilityApiVersion(input: string): string | boolean { + return validateFieldValue(input, 'radiologyExtensibilityApiVersion', 'manifest'); +} + /** * Validates tenant ID input (GUID format) */ @@ -93,7 +101,7 @@ export function validateTenantId(input: string): string | boolean { export async function promptExtensionDetails(defaults?: Partial): Promise { const name = await input({ message: 'Extension name:', - default: defaults?.name || 'my-radiology-extension', + default: defaults?.name || 'myRadiologyExtension', validate: validateExtensionName }); @@ -108,7 +116,13 @@ export async function promptExtensionDetails(defaults?: Partial { +export async function promptOutputDetails(defaults?: { name?: string; description?: string; schemaVersion?: string }): Promise { const name = await input({ message: 'Output name:', - default: defaults?.name || 'quality-check-result' + default: defaults?.name || 'qualityCheckResult' }); const description = await input({ @@ -244,10 +258,16 @@ export async function promptOutputDetails(defaults?: { name?: string; descriptio default: defaults?.description || 'Quality check result' }); + const schemaVersion = await input({ + message: 'Output payload schemaVersion (major.minor):', + default: defaults?.schemaVersion || '1.0' + }); + return { name, description, - 'content-type': 'application/vnd.ms-dragon.rad.quality-check-result+json' + 'content-type': 'application/vnd.ms-dragon.rad.quality-check-result+json', + schemaVersion }; } diff --git a/tools/dragon-copilot-cli/src/domains/radiology/templates/index.ts b/tools/dragon-copilot-cli/src/domains/radiology/templates/index.ts index 86dd238..2a76955 100644 --- a/tools/dragon-copilot-cli/src/domains/radiology/templates/index.ts +++ b/tools/dragon-copilot-cli/src/domains/radiology/templates/index.ts @@ -2,33 +2,37 @@ import type { TemplateConfig } from '../types.js'; const templates: Record = { 'quality-check': { - name: 'my-quality-check-extension', - description: 'Provides radiology report quality checking', + name: 'sampleQualityCheckExtension', + description: 'Extension to provide radiology report quality checking', version: '0.0.1', + radiologyExtensibilityApiVersion: '1.0.0', tools: [ { - name: 'report-quality-checker', + name: 'sampleQualityCheckTool', toolType: 'contractBased', capability: 'qualityCheck', - description: 'Checks the quality of a radiology report', + description: 'Tool to check quality of a radiology report', endpoint: 'https://publisher.example.com/quality-check', inputs: [ { name: 'report', description: 'Radiology report from Dragon Copilot', - 'content-type': 'application/vnd.ms-dragon.rad.report+json' + 'content-type': 'application/vnd.ms-dragon.rad.report+json', + schemaVersion: '1.0' }, { - name: 'patient-info', + name: 'patientInformation', description: 'Patient demographic information from Dragon Copilot', - 'content-type': 'application/vnd.ms-dragon.rad.patient-info+json' + 'content-type': 'application/vnd.ms-dragon.rad.patient-information+json', + schemaVersion: '1.0' } ], outputs: [ { - name: 'quality-check-result', + name: 'qualityCheckResult', description: 'Quality check findings and score', - 'content-type': 'application/vnd.ms-dragon.rad.quality-check-result+json' + 'content-type': 'application/vnd.ms-dragon.rad.quality-check-result+json', + schemaVersion: '1.0' } ], relevanceFilteringCriteria: { diff --git a/tools/dragon-copilot-cli/src/domains/radiology/types.ts b/tools/dragon-copilot-cli/src/domains/radiology/types.ts index f402cce..1d6d738 100644 --- a/tools/dragon-copilot-cli/src/domains/radiology/types.ts +++ b/tools/dragon-copilot-cli/src/domains/radiology/types.ts @@ -2,6 +2,7 @@ export interface DcrExtensionManifest { name: string; description: string; version: string; + radiologyExtensibilityApiVersion: string; auth: AuthConfig; tools: DcrTool[]; } @@ -26,6 +27,7 @@ export interface DcrInput { name: string; description: string; 'content-type': string; + schemaVersion: string; required?: boolean; } @@ -33,6 +35,7 @@ export interface DcrOutput { name: string; description: string; 'content-type': string; + schemaVersion: string; } export interface RelevanceFilteringCriteria { @@ -64,6 +67,7 @@ export interface TemplateConfig { name: string; description: string; version: string; + radiologyExtensibilityApiVersion: string; tools: ToolTemplate[]; } @@ -77,12 +81,14 @@ export interface ToolTemplate { name: string; description: string; 'content-type': string; + schemaVersion: string; required?: boolean; }>; outputs: Array<{ name: string; description: string; 'content-type': string; + schemaVersion: string; }>; relevanceFilteringCriteria?: RelevanceFilteringCriteria; configurationTemplate?: Record; diff --git a/tools/dragon-copilot-cli/src/schemas/radiology/radiology-extension-manifest-schema.json b/tools/dragon-copilot-cli/src/schemas/radiology/radiology-extension-manifest-schema.json index 89f792c..9da43f9 100644 --- a/tools/dragon-copilot-cli/src/schemas/radiology/radiology-extension-manifest-schema.json +++ b/tools/dragon-copilot-cli/src/schemas/radiology/radiology-extension-manifest-schema.json @@ -6,15 +6,16 @@ "name", "description", "version", + "radiologyExtensibilityApiVersion", "auth", "tools" ], "properties": { "name": { "type": "string", - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "pattern": "^[a-z][a-zA-Z0-9]*$", "minLength": 1, - "description": "Extension name (lowercase kebab-case: letters, numbers, and hyphens only)" + "description": "Extension name (camelCase: must start with a lowercase letter, followed by letters and numbers)" }, "description": { "type": "string", @@ -24,7 +25,12 @@ "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$", - "description": "Version in x.y.z format" + "description": "Partner's own version for this extension, in x.y.z format" + }, + "radiologyExtensibilityApiVersion": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "Version of the Radiology Extensibility API (radiology-extensibility-api.yaml info.version) this manifest was authored against, in x.y.z format" }, "auth": { "$ref": "#/definitions/AuthConfig", @@ -69,9 +75,9 @@ "properties": { "name": { "type": "string", - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "pattern": "^[a-z][a-zA-Z0-9]*$", "minLength": 1, - "description": "Tool name (lowercase kebab-case: letters, numbers, and hyphens only)" + "description": "Tool name (camelCase: must start with a lowercase letter, followed by letters and numbers)" }, "toolType": { "type": "string", @@ -130,7 +136,8 @@ "required": [ "name", "description", - "content-type" + "content-type", + "schemaVersion" ], "properties": { "name": { @@ -150,6 +157,9 @@ }, "content-type": { "$ref": "#/definitions/InputContentType" + }, + "schemaVersion": { + "$ref": "#/definitions/PayloadSchemaVersion" } }, "additionalProperties": false @@ -159,7 +169,8 @@ "required": [ "name", "description", - "content-type" + "content-type", + "schemaVersion" ], "properties": { "name": { @@ -174,6 +185,9 @@ }, "content-type": { "$ref": "#/definitions/OutputContentType" + }, + "schemaVersion": { + "$ref": "#/definitions/PayloadSchemaVersion" } }, "additionalProperties": false @@ -182,16 +196,21 @@ "type": "string", "enum": [ "application/vnd.ms-dragon.rad.report+json", - "application/vnd.ms-dragon.rad.patient-info+json" + "application/vnd.ms-dragon.rad.patient-information+json" ], - "description": "Content type identifier for tool inputs (IANA MIME type)" + "description": "Media type for a tool input. The '+json' subtype is the kebab-case form of the payload's schema object name in radiology-extensibility-api.yaml (e.g. 'application/vnd.ms-dragon.rad.report+json' maps to the Report schema)." }, "OutputContentType": { "type": "string", "enum": [ "application/vnd.ms-dragon.rad.quality-check-result+json" ], - "description": "Content type identifier for tool outputs (IANA MIME type)" + "description": "Media type for a tool output. The '+json' subtype is the kebab-case form of the payload's schema object name in radiology-extensibility-api.yaml (e.g. 'application/vnd.ms-dragon.rad.quality-check-result+json' maps to the QualityCheckResult schema)." + }, + "PayloadSchemaVersion": { + "type": "string", + "pattern": "^\\d+\\.\\d+$", + "description": "Version of the payload schema, in major.minor format (e.g. \"0.1\"), that the extension accepts for this input or produces for this output. Declared at manifest upload time so the platform knows which version of each payload to exchange with the extension." }, "RelevanceFilteringCriteria": { "$comment": "Tool-level gate: only consider invoking this tool if the current context (e.g., the study being reported on) matches these criteria. If omitted, the tool is always considered.", From 597ab17526bcc05bf5c2fcb028c006247a4e39d9 Mon Sep 17 00:00:00 2001 From: Ashok Ginjala Date: Fri, 12 Jun 2026 13:16:38 -0400 Subject: [PATCH 2/7] Address PR review feedback - README: clarify versioning section; extensibilityApiVersion may appear on the request envelope as informational metadata - AI sample: async end-to-end pipeline (IQualityCheckService.ProcessAsync) with CancellationToken propagation through to chat completion - AI sample: gracefully handle malformed model output (try/catch around JSON parse + TryGetProperty) so the extension returns a well-formed response with empty recommendations instead of a 500 --- radiology/README.md | 4 +- .../Controllers/QualityCheckController.cs | 6 +- .../Services/IQualityCheckService.cs | 2 +- .../Services/QualityCheckService.cs | 66 ++++++++++++++----- 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/radiology/README.md b/radiology/README.md index 889ee70..810dca1 100644 --- a/radiology/README.md +++ b/radiology/README.md @@ -27,9 +27,9 @@ Key resources: ### Versioning -Three independent version axes appear in these artifacts. They are **declarations recorded at manifest upload time** — none is transmitted on each `POST /v1/process` request: +Three independent version axes appear in these artifacts. They are **declarations recorded at manifest upload time** and are not part of each `POST /v1/process` payload (apart from the optional `extensibilityApiVersion` field on the request envelope, which is informational): -- **API version** — `info.version` in [`radiology-extensibility-api.yaml`](radiology-extensibility-api.yaml) (semantic `x.y.z`). The version of the extensibility API contract as a whole. A Partner records the version they built against in their manifest's `radiologyExtensibilityApiVersion` field. +- **API version** — `info.version` in [`radiology-extensibility-api.yaml`](radiology-extensibility-api.yaml) (semantic `x.y.z`). The version of the extensibility API contract as a whole. A Partner records the version they built against in their manifest's `radiologyExtensibilityApiVersion` field. The same API version may also appear on each request as the optional `extensibilityApiVersion` field on the `ProcessRequest` envelope (informational). - **Extension version** — the manifest's top-level `version` field (`x.y.z`). The Partner's own product version for their extension, independent of the API version. - **Payload schema version** — each payload schema (`Report`, `PatientInformation`, `QualityCheckResult`) declares its own version via the `x-ms-schema-version` annotation in [`radiology-extensibility-api.yaml`](radiology-extensibility-api.yaml) (`major.minor`). The Partner declares which version of each payload they accept (inputs) or produce (outputs) via the required `schemaVersion` field on every input and output in their manifest. This gives per-payload traceability — e.g. "this extension accepts `Report` v1.0" — without putting a version on the wire payloads themselves. diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Controllers/QualityCheckController.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Controllers/QualityCheckController.cs index e1969aa..80c99fb 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Controllers/QualityCheckController.cs +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Controllers/QualityCheckController.cs @@ -34,14 +34,14 @@ public QualityCheckController(IQualityCheckService qualityCheckService, ILogger< /// /// /// This sample uses Azure OpenAI when configured, falling back to an on-device - /// Foundry Local model. Replace with + /// Foundry Local model. Replace with /// your real implementation. /// [HttpPost("process")] [ProducesResponseType(typeof(ProcessResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public ActionResult Post([FromBody] ProcessRequest payload) + public async Task> PostAsync([FromBody] ProcessRequest payload, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(payload); @@ -56,7 +56,7 @@ public ActionResult Post([FromBody] ProcessRequest payload) Request.Path, payload.SessionData.CorrelationId); - var result = _qualityCheckService.Process(payload); + var result = await _qualityCheckService.ProcessAsync(payload, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "Response {Method} {Path} - Success: {Success} - Message: {Message} - Response Body: {ResponseBody}", diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IQualityCheckService.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IQualityCheckService.cs index 230e5bd..be85bcc 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IQualityCheckService.cs +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/IQualityCheckService.cs @@ -9,5 +9,5 @@ namespace SampleExtension.Radiology.Web.Ai.Services; /// public interface IQualityCheckService { - ProcessResponse Process(ProcessRequest payload); + Task ProcessAsync(ProcessRequest payload, CancellationToken cancellationToken = default); } diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/QualityCheckService.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/QualityCheckService.cs index 1f3a68c..4ad43a4 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/QualityCheckService.cs +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Services/QualityCheckService.cs @@ -105,7 +105,7 @@ public QualityCheckService( } /// - public ProcessResponse Process(ProcessRequest payload) + public async Task ProcessAsync(ProcessRequest payload, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(payload); @@ -117,7 +117,7 @@ public ProcessResponse Process(ProcessRequest payload) if (_azureOpenAi.IsConfigured) { _logger.LogInformation("Using Azure OpenAI provider."); - return ProcessWithChatClient(payload, _azureOpenAi.GetChatClient()); + return await ProcessWithChatClientAsync(payload, _azureOpenAi.GetChatClient(), cancellationToken).ConfigureAwait(false); } if (_foundryLocalSettings.Enabled) @@ -125,16 +125,17 @@ public ProcessResponse Process(ProcessRequest payload) _logger.LogInformation( "Using Foundry Local provider (model={Model}). If this is the first request after startup, the model may need to download and load — this can take several minutes.", _foundryLocalSettings.ModelAlias); - // GetChatClientAsync is lazily memoized; first call may be slow due to model download/load. - var chatClient = _foundryLocal.GetChatClientAsync().GetAwaiter().GetResult(); - return ProcessWithChatClient(payload, chatClient); + // GetChatClientAsync is lazily memoized; first call may be slow due to model download/load, + // but awaiting it (rather than blocking) keeps the ASP.NET Core thread-pool free. + var chatClient = await _foundryLocal.GetChatClientAsync(cancellationToken).ConfigureAwait(false); + return await ProcessWithChatClientAsync(payload, chatClient, cancellationToken).ConfigureAwait(false); } throw new InvalidOperationException( "No model provider configured. Set Azure OpenAI Endpoint/ApiKey/DeploymentName, or set FoundryLocal.Enabled=true in appsettings.json."); } - private ProcessResponse ProcessWithChatClient(ProcessRequest payload, ChatClient chatClient) + private async Task ProcessWithChatClientAsync(ProcessRequest payload, ChatClient chatClient, CancellationToken cancellationToken) { var prompt = JsonSerializer.Serialize(new { @@ -146,11 +147,11 @@ private ProcessResponse ProcessWithChatClient(ProcessRequest payload, ChatClient } }); - var json = RunChatCompletion(chatClient, prompt); + var json = await RunChatCompletionAsync(chatClient, prompt, cancellationToken).ConfigureAwait(false); return MapToResult(json); } - internal static string RunChatCompletion(ChatClient chatClient, string userMessage) + internal static async Task RunChatCompletionAsync(ChatClient chatClient, string userMessage, CancellationToken cancellationToken) { List messages = new List() { @@ -158,7 +159,7 @@ internal static string RunChatCompletion(ChatClient chatClient, string userMessa new UserChatMessage(userMessage), }; - var response = chatClient.CompleteChat(messages, new ChatCompletionOptions()); + var response = await chatClient.CompleteChatAsync(messages, new ChatCompletionOptions(), cancellationToken).ConfigureAwait(false); return response.Value.Content[0].Text; } @@ -183,17 +184,46 @@ private ProcessResponse MapToResult(string json) json = json.Trim(); } - var root = JsonDocument.Parse(json); - var qualityCheckResultElement = root.RootElement.GetProperty(qualityCheckResultPropertyName); - var qualityCheckResult = qualityCheckResultElement.Deserialize(DeserializeOptions); + // Models can return malformed JSON, the wrong shape, or omit expected properties. + // Catch parse/deserialize failures so the extension always returns a well-formed + // ProcessResponse instead of bubbling a 500 to the caller. Partners adapting this + // sample can replace the fallback with their own error-handling strategy. + QualityCheckResult? qualityCheckResult = null; + try + { + using var root = JsonDocument.Parse(json); + if (root.RootElement.ValueKind == JsonValueKind.Object + && root.RootElement.TryGetProperty(qualityCheckResultPropertyName, out var qualityCheckResultElement)) + { + qualityCheckResult = qualityCheckResultElement.Deserialize(DeserializeOptions); + } + else + { + _logger.LogWarning( + "Model response did not contain expected '{Property}' property. Raw response: {Json}", + qualityCheckResultPropertyName, + json); + } + } + catch (JsonException ex) + { + _logger.LogWarning( + ex, + "Failed to parse model response as JSON. Raw response: {Json}", + json); + } - var response = new ProcessResponse + var parsed = qualityCheckResult is not null; + return new ProcessResponse { - Success = true, - Message = "Payload processed successfully.", - Payload = new Dictionary(), + Success = parsed, + Message = parsed + ? "Payload processed successfully." + : "Model returned malformed output; returning empty recommendations.", + Payload = new Dictionary + { + [qualityCheckResultPropertyName] = qualityCheckResult ?? new QualityCheckResult(), + }, }; - response.Payload[qualityCheckResultPropertyName] = qualityCheckResult ?? new QualityCheckResult(); - return response; } } From 1e1c7458e65cb3bcbce355d67356bae9348d9dd0 Mon Sep 17 00:00:00 2001 From: Ashok Ginjala Date: Tue, 16 Jun 2026 19:32:44 -0400 Subject: [PATCH 3/7] Add Python radiology quickstart sample and scaffold prompt --- .../csharp-sample.instructions.md | 59 +++++ .../instructions/physician.instructions.md | 92 +++++++ .../python-sample.instructions.md | 81 ++++++ .../instructions/radiology.instructions.md | 115 +++++++++ ...diology-scaffold-language-sample.prompt.md | 191 +++++++++++++++ radiology/src/samples/Workflow/README.md | 18 +- .../Program.cs | 16 +- .../README.md | 11 +- .../Controllers/QualityCheckController.cs | 6 +- .../Program.cs | 16 +- .../README.md | 43 +++- .../Services/IQualityCheckService.cs | 2 +- .../Services/QualityCheckService.cs | 4 +- ...lnx => SampleExtension.Radiology.Web.slnx} | 0 .../.env.example | 21 ++ .../.gitignore | 12 + .../MockData/qualitycheck_response.json | 49 ++++ .../README.md | 230 ++++++++++++++++++ .../app/__init__.py | 0 .../app/auth.py | 137 +++++++++++ .../app/config.py | 85 +++++++ .../app/main.py | 99 ++++++++ .../app/models.py | 146 +++++++++++ .../app/service.py | 94 +++++++ .../app/tests/__init__.py | 0 .../app/tests/conftest.py | 60 +++++ .../app/tests/test_auth.py | 96 ++++++++ .../app/tests/test_process.py | 43 ++++ .../extension.yaml | 26 ++ .../requirements.txt | 7 + ...extension_radiology_python_quickstart.http | 28 +++ 31 files changed, 1772 insertions(+), 15 deletions(-) create mode 100644 .github/instructions/csharp-sample.instructions.md create mode 100644 .github/instructions/physician.instructions.md create mode 100644 .github/instructions/python-sample.instructions.md create mode 100644 .github/instructions/radiology.instructions.md create mode 100644 .github/prompts/radiology-scaffold-language-sample.prompt.md rename radiology/src/samples/Workflow/{SampleExtensions.Radiology.Web.slnx => SampleExtension.Radiology.Web.slnx} (100%) create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/.env.example create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/.gitignore create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/MockData/qualitycheck_response.json create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/README.md create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/__init__.py create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/auth.py create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/config.py create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/main.py create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/models.py create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/service.py create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/__init__.py create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/conftest.py create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/test_auth.py create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/test_process.py create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/extension.yaml create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/requirements.txt create mode 100644 radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/sample_extension_radiology_python_quickstart.http diff --git a/.github/instructions/csharp-sample.instructions.md b/.github/instructions/csharp-sample.instructions.md new file mode 100644 index 0000000..e4017a3 --- /dev/null +++ b/.github/instructions/csharp-sample.instructions.md @@ -0,0 +1,59 @@ +--- +applyTo: '**/*.cs,**/*.csproj' +--- + +# C# Sample Conventions — Copilot Instructions + +Shared C# conventions used across the C# samples in this repository. This repository contains samples for multiple Dragon Copilot products (Physician, Radiology, and potentially others in the future). Product-specific patterns live in the matching `.instructions.md` overlay (loaded automatically via `applyTo: /**`). + +## Stack + +- ASP.NET Core (Web SDK, `Microsoft.NET.Sdk.Web`). +- Target framework varies by product and sample: Physician uses `net9.0`; Radiology Quickstart uses `net10.0`; Radiology AI uses `net10.0-windows10.0.26100` (Windows-only due to Foundry Local). Check the product overlay or `.csproj` for the exact TFM. +- `Microsoft.Identity.Web` and `Microsoft.AspNetCore.Authentication.JwtBearer` for Entra ID JWT bearer authentication. +- `System.Text.Json` for serialization, with `JsonStringEnumConverter` registered for enum-as-string serialization and `PropertyNameCaseInsensitive = true`. +- `Swashbuckle.AspNetCore` for OpenAPI / Swagger documentation in Development. + +## Endpoint and controller pattern + +- Single-purpose controller per extension capability, decorated with `[ApiController]`, `[Route("v1")]`, `[Produces("application/json")]`. +- Apply `[Authorize(Policy = "RequiredClaims")]` to the process endpoint, either at the controller level or on the action method. Do not use anonymous routes for business endpoints. +- POST the process endpoint at `v1/process` and return `Task>`. All samples use an async controller action that accepts a `CancellationToken` parameter to support request cancellation. +- Include explicit `[ProducesResponseType]` attributes covering at minimum `200`, `400`, `500`. Add `401` / `403` when the sample stacks additional auth layers on top of JWT. + +## Authentication pattern + +- Configure authentication via the `AddCustomAuthentication(IConfiguration)` extension method. +- The "RequiredClaims" authorization policy uses a disabled-mode pass-through (`RequireAssertion(_ => true)`) when `Authentication.Enabled` is `false` so disabled-mode requests pass through cleanly. +- In enabled mode the policy chains `RequireAuthenticatedUser()` plus one `RequireClaim` call per entry under `RequiredClaims` in configuration. +- The JWT bearer `OnAuthenticationFailed` handler parses the incoming bearer header and logs the actual audience versus the expected audience, which makes "wrong audience" failures easy to diagnose. +- An `OnTokenValidated` handler logs the inbound claims that the policy will evaluate. + +## Pipeline pattern + +- Health-check endpoints return a JSON body (e.g. `{"status":"Healthy"}`); samples either use `MapGet` returning `Results.Ok(...)` or a `HealthCheckOptions.ResponseWriter` that writes JSON. Route names vary by product (Physician uses `/health`; Radiology uses `/health/liveness` and `/health/readiness`). +- Wire up the security pipeline via `app.UseFullSecurity()`. +- `UseFullSecurity` wraps `UseAuthentication` and `UseAuthorization` inside `app.UseWhen(ctx => !isPublicRoute(ctx), ...)` so public routes bypass JWT validation. +- Public routes: + - `/health/liveness` + - `/health/readiness` + - Swagger UI at the application root in Development + +## Coding conventions + +- Mark concrete classes as `sealed` unless they are intended for inheritance. +- Null-check constructor parameters at the top with `ArgumentNullException.ThrowIfNull(...)`. +- Use the options pattern (`IOptions`) for configuration sections; do not pass `IConfiguration` to deep collaborators. +- Use primary constructors or explicit constructor injection. Use `ILogger` for logging. +- Sample projects suppress documentation analyzers commonly noisy on minimal samples (CA1812, CA1515, CA1848, CA1873, CS1591, CA2227). + +## Out of scope + +The following patterns appear in some samples but are **not** universal across the repository. Do not assume them when generating new C# code unless the product overlay calls them out: + +- Additional business-auth middleware on top of JWT (e.g. license-key validation). +- A dedicated static class for public-route lists (some samples use an inline string array instead). +- Source-generated `[LoggerMessage]` partials. +- A per-sample `extension.yaml` manifest checked into the C# sample folder. + +When working inside a specific product folder, also follow that product's overlay (e.g. `physician.instructions.md`, `radiology.instructions.md`). diff --git a/.github/instructions/physician.instructions.md b/.github/instructions/physician.instructions.md new file mode 100644 index 0000000..9b95cb3 --- /dev/null +++ b/.github/instructions/physician.instructions.md @@ -0,0 +1,92 @@ +--- +applyTo: 'physician/**' +--- + +# Physician Extension Samples — Copilot Instructions + +> Physician owners own this file. Extend or refine it as your samples evolve. + +## Sample layout + +- C# sample: `physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/` +- Python sample: `physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/` +- Models project: `physician/src/models/Dragon.Copilot.Physician.Models/` +- OpenAPI contract: `physician/physician-extensibility-api.yaml` +- Quickstart guide: `physician/QUICKSTART.md` + +## Stack facts + +- C# sample targets **.NET 9.0**, ASP.NET Core Web API. +- Default dev ports: **5181** (HTTP), **7156** (HTTPS). +- Authentication is enabled by default in `appsettings.json` and can be toggled per environment. +- The C# sample includes a custom `LicenseKeyMiddleware` for subscription / feature-key validation in addition to JWT auth. + +## Core data models + +The Physician models project defines the canonical clinical types used by the extension contract. Key DTOs include: + +- `DragonStandardPayload` — top-level payload carrying session data and clinical context +- `Note` — clinical notes and documentation +- `Transcript` — speech-to-text transcriptions +- `IterativeTranscript` / `IterativeAudio` — real-time streaming data +- `Patient` / `Practitioner` — healthcare entities +- `Encounter` — clinical visits and sessions +- `MedicalCode` — standardized medical coding (ICD, SNOMED, etc.) + +## Service entry point + +The Physician sample exposes its business logic through `IProcessingService` (see `Services/IProcessingService.cs`). To replace the stubbed extraction logic with your own, edit `Services/ProcessingService.cs` — this is the single integration point. + +## Endpoint shape + +```csharp +[ApiController] +[Route("v1")] +public class ProcessController : ControllerBase +{ + [HttpPost("process")] + [Authorize(Policy = "RequiredClaims")] + public async Task> Process( + [FromBody] DragonStandardPayload payload, + CancellationToken cancellationToken = default) +} +``` + +## Authentication layers + +Physician samples use two layers stacked on top of each other: + +1. **JWT bearer authentication** via Microsoft Entra ID and Microsoft Identity Web. +2. **License key validation** via a custom middleware (`LicenseKeyMiddleware`) for business-level subscription and feature gating. + +Both layers can be disabled in development through `appsettings.Development.json`. + +## Manifest format (Physician) + +```yaml +name: extension-name +description: Extension description +version: 0.0.1 +auth: + tenantId: 00000000-0000-0000-0000-000000000000 +tools: + - name: tool-name + description: Tool description + endpoint: https://api.example.com/v1/process + trigger: AutoRun # AutoRun (default) or AdaptiveCardAction + inputs: + - name: note + description: Clinical note input + content-type: application/vnd.ms-dragon.dsp.note+json + outputs: + - name: processed-data + description: Processed results + content-type: application/vnd.ms-dragon.dsp+json +``` + +## Local development workflow + +1. `dotnet run --project physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web`. +2. Open Swagger at `http://localhost:5181/`. +3. Health probes at `/health/liveness` and `/health/readiness`. +4. Use the bundled `.http` files in the sample folder for quick request testing. diff --git a/.github/instructions/python-sample.instructions.md b/.github/instructions/python-sample.instructions.md new file mode 100644 index 0000000..47ed79a --- /dev/null +++ b/.github/instructions/python-sample.instructions.md @@ -0,0 +1,81 @@ +--- +applyTo: '**/*.py,**/requirements.txt,**/pyproject.toml' +--- + +# Python Sample Conventions — Copilot Instructions + +Shared Python conventions for sample extensions in this repository. This repository contains samples for multiple Dragon Copilot products (Physician, Radiology, and potentially others in the future). Product-specific patterns live in the matching `.instructions.md` overlay. + +## Stack + +| Component | Version | +| --- | --- | +| Python | 3.12 | +| FastAPI | 0.116.1 | +| uvicorn | 0.35.0 | +| pydantic | 2.11.7 | +| pydantic-settings | 2.10.1 | +| pytest | 9.0.3 | + +Pin these exact versions in `requirements.txt`. Use `requirements.txt`, not `pyproject.toml`, to match the existing precedent and keep installation simple for partners. + +> Python 3.12 is recommended over 3.14 because several ML / AI Python SDKs do not yet fully support 3.14. + +## Project layout + +``` +/ +├── README.md +├── requirements.txt +└── app/ + ├── __init__.py + ├── main.py # FastAPI app, /v1/process, /health endpoints + ├── config.py # pydantic-settings + ├── models.py # Pydantic mirrors of the C# models (Physician: DragonStandardPayload; Radiology: ProcessRequest/ProcessResponse) + ├── service.py # Business logic / mock data fallback + └── tests/ + ├── __init__.py + └── test_*.py +``` + +## Endpoint pattern + +- Use FastAPI's decorator-based routing: `@app.post("/v1/process")`. +- Define request and response models with Pydantic; do not return raw dicts. The exact DTO types vary by product — Physician uses `DragonStandardPayload`, Radiology uses `ProcessRequest`/`ProcessResponse`. Mirror the corresponding C# models project (`physician/src/models/` or `radiology/src/models/`). +- Health endpoints at `/health/liveness` and `/health/readiness` return JSON status payloads, matching the C# samples and the scaffold prompt. +- Enable FastAPI's automatic Swagger / OpenAPI generation; expose it at `/docs`. + +## Configuration + +- Use `pydantic_settings.BaseSettings` in `app/config.py`. +- Load `.env` via `model_config = SettingsConfigDict(env_file=".env")`. +- Authentication is disabled by default for development. Document the flag in the sample's README so partners enable it intentionally when they harden for production. + +## Naming + +- snake_case for filenames inside the `app/` package. +- Mock data filenames use the language's idiomatic casing (e.g., `qualitycheck_response.json` for Radiology). The exact filename varies — check the corresponding C# sample's `MockData/` folder. +- PascalCase for Pydantic model classes mirroring C# DTOs (`Report`, `PatientInformation`, `DragonStandardPayload`, etc.). +- snake_case for field names exposed by Pydantic; use `alias` / `populate_by_name` if the JSON contract uses camelCase. + +## Running locally + +```powershell +python3.12 -m venv .venv +. .\.venv\Scripts\Activate.ps1 +python3.12 -m pip install --upgrade pip +python3.12 -m pip install -r requirements.txt +python3.12 -m uvicorn app.main:app --host 0.0.0.0 --port --reload +``` + +```bash +python3.12 -m venv .venv && source .venv/bin/activate && python3.12 -m pip install --upgrade pip && python3.12 -m pip install -r requirements.txt +python3.12 -m uvicorn app.main:app --host 0.0.0.0 --port --reload +``` + +Pick a port that matches the C# sample default for the same product (Physician: 5181, Radiology: 5080) so partners can swap implementations without changing client URLs. + +## Testing + +- Use `pytest` and FastAPI's `TestClient` (`from fastapi.testclient import TestClient`). +- Keep tests minimal: happy-path `/v1/process`, health endpoints, and deserialization of the mock response. diff --git a/.github/instructions/radiology.instructions.md b/.github/instructions/radiology.instructions.md new file mode 100644 index 0000000..a10bcec --- /dev/null +++ b/.github/instructions/radiology.instructions.md @@ -0,0 +1,115 @@ +--- +applyTo: "radiology/**" +--- + +# Radiology Extension Samples — Copilot Instructions + +Radiology extensions analyze radiology reports and return quality-check recommendations. + +## Authoritative contract + +- **OpenAPI spec:** `radiology/radiology-extensibility-api.yaml` is the canonical wire contract for `POST /v1/process`. It defines the envelope as `ProcessRequest` (request) and `ProcessResponse` (response), and contains the full schema definitions for all Radiology domain types (`SessionData`, `PatientInformation`, `Report`, `QualityCheckResult`, `Recommendation`, `Provenance`, `ReferenceResource`). +- **Models project:** `radiology/src/models/Dragon.Copilot.Radiology.Models/` — C# classes that mirror the OpenAPI spec (`ProcessRequest`, `ProcessResponse`, `SessionData`, `PatientInformation`, `Report`, `QualityCheckResult`, …). The wire envelope lives **here**, not in each sample. +- **Wire shape (from the spec):** + - Request `ProcessRequest`: required `sessionData`; optional `extensibilityApiVersion` (string, e.g. `"1.1.1"`, informational metadata from Dragon Copilot), `patientInformation`, and `report`. Additional named inputs flow through `additionalProperties`. The Radiology C# model declares `patientInformation` and `report` as explicit properties for convenience. + - Response `ProcessResponse`: optional `success`, `message`, and `payload` — a **map** of output name → `QualityCheckResult` (the output name comes from the extension's manifest, e.g. `qualityCheckResult`). +- **Field casing on the wire is mixed**, matching the YAML: top-level uses camelCase (`extensibilityApiVersion`, `sessionData`, `patientInformation`, `report`); `SessionData` fields are snake_case (`correlation_id`, `session_start`, `environment_id`); `PatientInformation` and `Report` fields are camelCase (`dateOfBirth`, `biologicalSex`, `reportText`). + +## Sample variants + +Two C# sample variants live under `radiology/src/samples/Workflow/`: + +| Variant | Folder | Purpose | Target | Platform | +| ---------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------- | +| Quickstart | `SampleExtension.Radiology.Web.Quickstart/` | Returns a canned response from `MockData/qualitycheck-response.json`. The fastest way to get a working extension running locally. | `net10.0` | Cross-platform | +| Ai | `SampleExtension.Radiology.Web.Ai/` | Calls Azure OpenAI when its config is populated; otherwise calls Foundry Local on-device inference when enabled. Throws if neither is configured. | `net10.0-windows10.0.26100` | Windows-only (Foundry Local) | + +## Stack facts + +- C# target framework: `net10.0` (Quickstart), `net10.0-windows10.0.26100` (AI sample, due to Foundry Local dependency). +- Default dev ports: **5080** (HTTP), **7080** (HTTPS). +- `Authentication.Enabled` is `false` by default in `appsettings.json` so partners can clone and run without setting up Entra ID first. +- Health probes at `/health/liveness` and `/health/readiness`, returning a JSON body (e.g. `{"status":"Healthy"}`) via a health-check response writer. +- Swagger UI is served at the application root in Development. + +## Domain types + +Defined in `Dragon.Copilot.Radiology.Models`: + +- `Report` — the radiology report text and metadata +- `PatientInformation` — patient demographics relevant to the report +- `QualityCheckResult` — the structured result returned to Dragon Copilot +- `Recommendation` — an individual quality-check finding +- `Provenance` — the span of report text a recommendation was derived from (`text`, `startPosition`, `endPosition`) +- `ReferenceResource` — supporting references attached to a recommendation +- `BiologicalSex`, `QualityCheckType` — enums (`Billing`, `Clinical`) + +## Quality-check service + +Both sample variants use `IQualityCheckService.ProcessAsync` (async with `CancellationToken`) as the single integration point. Replace its implementation to wire in your own logic. + +- **Quickstart variant:** Returns the canned response in `MockData/qualitycheck-response.json`. Partners can edit the JSON directly to tweak the stubbed output without rebuilding (the file is copied to the build output with `PreserveNewest`). +- **Ai variant:** Selects a provider per request from configuration: + 1. **Azure OpenAI** when the `OpenAI` section in `appsettings.json` has `Endpoint`, `ApiKey`, and `DeploymentName` populated. + 2. Otherwise **Foundry Local** when `FoundryLocal:Enabled` is `true`. + + **Graceful fallback:** If the model returns malformed JSON or omits the expected `qualityCheckResult` property, the service logs a warning and returns a well-formed `ProcessResponse` with an empty recommendations list instead of throwing. Partners adapting this sample can replace this fallback with their own error-handling strategy. + + The full AI system prompt lives in code at `SampleExtension.Radiology.Web.Ai/Services/QualityCheckService.cs` as the private `SystemPrompt` const, so it stays in sync with the running code. + +## Endpoint shape + +Both samples use an async controller action with `CancellationToken`: + +```csharp +[ApiController] +[Route("v1")] +[Produces("application/json")] +[Authorize(Policy = "RequiredClaims")] +public sealed class QualityCheckController : ControllerBase +{ + [HttpPost("process")] + public async Task> PostAsync( + [FromBody] ProcessRequest payload, + CancellationToken cancellationToken) { ... } +} +``` + +## Manifest format (Radiology) + +Radiology extension manifests differ from Physician manifests. Key required fields: + +```yaml +name: sampleQualityCheckExtension # camelCase, starts lowercase +description: Extension to provide radiology report quality checking +version: 0.0.1 # Partner's own version (x.y.z) +radiologyExtensibilityApiVersion: 1.0.0 # API version from radiology-extensibility-api.yaml +auth: + tenantId: 00000000-0000-0000-0000-000000000000 +tools: + - name: sampleQualityCheckTool # camelCase, starts lowercase + toolType: contractBased # Required for Radiology + capability: qualityCheck # Required for Radiology + description: Tool to check quality of a radiology report + endpoint: https://publisher.example.com/quality-check + inputs: + - name: report + description: Radiology report from Dragon Copilot + content-type: application/vnd.ms-dragon.rad.report+json + schemaVersion: "1.0" # Required: version of Report schema accepted + - name: patientInformation + description: Patient demographic information from Dragon Copilot + content-type: application/vnd.ms-dragon.rad.patient-information+json + schemaVersion: "1.0" # Required: version of PatientInformation schema accepted + outputs: + - name: qualityCheckResult + description: Quality check findings and score + content-type: application/vnd.ms-dragon.rad.quality-check-result+json + schemaVersion: "1.0" # Required: version of QualityCheckResult schema produced +``` + +See `tools/dragon-copilot-cli/src/schemas/radiology/radiology-extension-manifest-schema.json` for the full JSON Schema. + +## Scaffolding a sample in another language + +When a partner wants a Radiology sample in a language other than C# (for example Python, Go, Java, or Node.js), invoke the reusable Copilot prompt at `.github/prompts/radiology-scaffold-language-sample.prompt.md`. Its usage instructions live inside the prompt file itself. diff --git a/.github/prompts/radiology-scaffold-language-sample.prompt.md b/.github/prompts/radiology-scaffold-language-sample.prompt.md new file mode 100644 index 0000000..763a52c --- /dev/null +++ b/.github/prompts/radiology-scaffold-language-sample.prompt.md @@ -0,0 +1,191 @@ +--- +description: "Scaffold a Radiology extension sample in the language of your choice, mirroring the C# Quickstart." +mode: agent +--- + +# Scaffold a Radiology sample in another language + +Generate a new Radiology extension sample, in the language the user provides, that implements the **same wire contract** as the C# Quickstart and can be checked into this repository once reviewed. Treat the rest of this file as your working instructions and reference material. + +## Start here — establish the target language + +Your first step is to confirm which language to scaffold: + +1. If the user has already named a language (in their message or via the `${input:language}` variable below), use it and continue straight to scaffolding. +2. Otherwise, reply with a single short question asking the user to choose one of `python`, `go`, `java`, `nodejs`, `typescript`, `rust`, or another language they specify, then wait for their answer. + +Keep that first reply to just the question. Begin reading the source material and scaffolding only once the language is known. + +## How a partner uses this prompt + +1. Open the repo in an editor with GitHub Copilot Chat enabled (for example Visual Studio or VS Code). +2. In Copilot Chat, type `/` and select `radiology-scaffold-language-sample`. +3. Provide the target language when asked (for example `python`, `go`, `java`, `nodejs`, `typescript`, `rust`). +4. Review, run, and adjust before committing. + +## Input + +- `${input:language:Target language for the new sample (e.g. python, go, java, nodejs, typescript, rust)}` + +## Source material to read first + +1. **`radiology/radiology-extensibility-api.yaml` is the canonical wire contract** for Radiology. It defines `POST /v1/process` with `ProcessRequest` as the request envelope and `ProcessResponse` as the response envelope, and contains the full schema definitions for all Radiology domain types. Read it first. +2. **The C# Quickstart shows the canonical implementation** of that contract. Read it end-to-end: + - `radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Controllers/QualityCheckController.cs` + - `.../Services/IQualityCheckService.cs`, `.../Services/QualityCheckService.cs` + - `.../MockData/qualitycheck-response.json` — canned response to reuse verbatim + - `.../appsettings.json` — `Authentication` configuration shape + - `.../Program.cs` — middleware order (CORS, Swagger-at-root in Development, health endpoints, auth) + - `.../README.md` — README structure to mirror + - `.../SampleExtension.Radiology.Web.Quickstart.http` — canonical sample payload (note: field casing mixes snake_case and camelCase per spec; reproduce it exactly) +3. **The shared models project** `radiology/src/models/Dragon.Copilot.Radiology.Models/` is where the wire envelope lives in C# (`ProcessRequest`, `ProcessResponse`, `SessionData`, `PatientInformation`, `Report`, `QualityCheckResult`, `Recommendation`, `Provenance`, `ReferenceResource`, `BiologicalSex`, `QualityCheckType`). The samples themselves do **not** have a `Models/` folder; mirror the types from this shared project. +4. Per-language overlay (load only if it exists for the chosen language): `.github/instructions/-sample.instructions.md`. If present, follow it strictly. + +## Wire contract (lock this down — non-negotiable) + +This section reflects `radiology/radiology-extensibility-api.yaml` and the canonical `.http` payload exactly. **Do not rename fields, do not change casing.** + +**`POST /v1/process`** request body (`ProcessRequest`): + + { + "extensibilityApiVersion": "1.1.1", // Optional, informational — Dragon Copilot may include this + "sessionData": { // Required + "correlation_id": "...", + "session_start": "...", + "environment_id": "..." + }, + "patientInformation": { // Optional + "dateOfBirth": "YYYY-MM-DD", + "biologicalSex": "Male|Female|Unknown|Other" + }, + "report": { "reportText": "..." } // Optional + } + +Response body (`ProcessResponse`): + + { + "success": true, + "message": "Payload processed successfully.", + "payload": { + // Map of output name -> QualityCheckResult. + // The output name comes from the extension manifest; the C# Quickstart uses "qualityCheckResult". + "qualityCheckResult": { + "recommendations": [ + /* Recommendation[] */ + ] + } + } + } + +Field casing is **mixed** and must be reproduced exactly: + +- Top-level request: `extensibilityApiVersion` (camelCase, optional), `sessionData` / `patientInformation` / `report` (camelCase). +- Top-level response: `success` / `message` / `payload` (camelCase). **No version field on the response.** +- `SessionData` fields are **snake_case**: `correlation_id`, `session_start`, `environment_id`. +- `PatientInformation` and `Report` fields are **camelCase**: `dateOfBirth`, `biologicalSex`, `reportText`. +- `payload` on the response is a **map**, not a fixed object. The key (`qualityCheckResult` in the canned mock) is declared by the extension's `extension.yaml` `outputs[].name`. + +Only `sessionData` is required on the request per the OpenAPI spec. `Recommendation`, `Provenance`, `ReferenceResource`, `BiologicalSex`, `QualityCheckType` mirror the shared `Dragon.Copilot.Radiology.Models` types exactly. + +## Folder and naming conventions + +Folder lives at `radiology/src/samples/Workflow//`. The folder name **reads as** "sample extension radiology `` quickstart" using the **language's idiomatic package-naming convention** (snake_case for Python/Rust, kebab-case for Node/TypeScript/Go/Java, dotted PascalCase for C#). + +Use the same lowercase language token (`python`, `nodejs`, `typescript`, `go`, `java`, `rust`) consistently in code (namespaces, packages, module names) and README headings. Do not introduce a separate PascalCase variant. + +## What the sample must do + +### API surface + +1. **Endpoint:** `POST /v1/process` accepting the request body above, returning the response body above. Bind to **HTTP port 5080** by default; document `--port` override in the README (5080 collides with the C# sample if both run on the same host). +2. **Health probes:** `GET /health/liveness` and `GET /health/readiness`, each returning a JSON body `{"status": "Healthy"}` on success (matching the C# samples, which write JSON from their health-check response writer). +3. **Domain types:** Mirror `Dragon.Copilot.Radiology.Models` in the target language. Internal code uses the language's idiomatic casing (snake_case in Python/Rust, exported PascalCase in Go, camelCase in Java/Kotlin/Node/TS). Wire JSON is **camelCase**; add a language-idiomatic alias/tag/annotation layer to map between the two (this is the same pattern C# uses with `[JsonPropertyName]`). + +### Implementation + +4. **Service layer:** A single `QualityCheckService` (or language equivalent) with one method that takes `ProcessRequest` and returns `ProcessResponse`. Use the language's idiomatic async pattern if the framework is async-first (the C# samples expose `ProcessAsync` with a cancellation token). The only logic is loading the canned mock data. Partners replace this method with their real implementation. +5. **Mock data:** Copy `radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/MockData/qualitycheck-response.json` **verbatim** to a top-level `MockData/` folder inside the new sample (match the C# layout — do not nest it under a source/package directory). Filename uses the language's idiomatic casing for resource files: + - Python / Rust → `qualitycheck_response.json` + - C# / Node / TypeScript → `qualitycheck-response.json` + - Java → `qualityCheckResponse.json` + - Go → `qualitycheckresponse.json` + +### Security & configuration + +6. **Authentication:** Implement **fully working** Microsoft Entra ID JWT bearer validation using a language-idiomatic, well-known library (agent picks the library and notes the choice in the README). Validation must check: + - Signature against Entra ID JWKS for the configured tenant + - Issuer matches `https://login.microsoftonline.com//v2.0` (or v1 equivalent) + - Audience matches the configured client ID + - At least one `azp` (or `appid`) claim is in the allowed-caller list + + **Toggle off by default** for local development via an `enabled` flag (mirrors the C# `Authentication.Enabled`). When off, all routes are anonymous. Use placeholders for tenant/client/allowed-caller IDs (``, ``, ``). Never check in real values. + +7. **Configuration:** Read settings from environment variables (prefix `DCR_RAD_`) with a language-idiomatic config layer. Match the keys in the C# `appsettings.json`'s `Authentication` section. + +### Cross-cutting behaviour + +8. **CORS:** Enable open CORS by default (mirrors the C# Program.cs); the README must include a warning that it must be locked down in production. +9. **Swagger / OpenAPI UI:** Wire `GET /` to redirect to the framework's OpenAPI UI only when that UI is bundled in the framework's standard distribution and requires no additional configuration, manual setup, or extra packages to display API documentation. Otherwise omit the redirect entirely. Do not add any package solely to provide an OpenAPI UI. +10. **Tracing headers:** Accept `x-ms-request-id` and `x-ms-correlation-id` if present, but do not validate, log, or echo them — match C#, which only logs `SessionData.correlationId` from the body. +11. **Error responses:** Use whatever the framework returns by default for 400/422/500. Do **not** add a custom error envelope. + +### Files & docs + +12. **`extension.yaml`:** Include a Radiology manifest at the sample root. **Radiology manifests differ from Physician manifests** — use this structure: + + name: sampleQualityCheckExtension # camelCase, starts lowercase + description: Sample radiology quality check extension + version: 0.0.1 + radiologyExtensibilityApiVersion: 1.0.0 # Required for Radiology + auth: + tenantId: 00000000-0000-0000-0000-000000000000 + tools: + - name: sampleQualityCheckTool + toolType: contractBased # Required for Radiology + capability: qualityCheck # Required for Radiology + description: Tool to check quality of a radiology report + endpoint: http://localhost:5080/v1/process + inputs: + - name: report + description: Radiology report from Dragon Copilot + content-type: application/vnd.ms-dragon.rad.report+json + schemaVersion: "1.0" # Required for Radiology + - name: patientInformation + description: Patient demographic information + content-type: application/vnd.ms-dragon.rad.patient-information+json + schemaVersion: "1.0" + outputs: + - name: qualityCheckResult + description: Quality check findings and score + content-type: application/vnd.ms-dragon.rad.quality-check-result+json + schemaVersion: "1.0" + + See `tools/dragon-copilot-cli/src/schemas/radiology/radiology-extension-manifest-schema.json` for the full JSON Schema. + +13. **README:** Mirror the structure of `radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/README.md`. Required sections: What's included, API endpoints (table), Run locally (separate Linux/macOS and Windows PowerShell blocks), Testing the API (with both `curl` and `Invoke-RestMethod` examples for `/v1/process` and both health probes), Security (with explicit Entra ID enable steps), Quality-check provider, Request/response contract. Add a Running the tests section as well (the C# samples ship no tests, but other-language samples do — see item 14). + +### Validation + +14. **Tests:** Ship a baseline test suite using the language's standard test framework. Required cases: + - `/health/liveness` returns 200 + `{"status": "Healthy"}` + - `/health/readiness` returns 200 when mock data is present + - `/v1/process` happy path against the canonical sample payload returns the canned response (assert 3 recommendations, the `Clinical` + `Billing` types present, `severityScorePercent` 85 on the "paddock steatosis" recommendation) + - `/v1/process` returns the framework's default validation error (typically 4xx) when required fields are missing + - Auth toggle: enabled ⇒ unauthenticated request returns 401; enabled + valid bearer token ⇒ 200 (mock the JWKS / signing key in tests so this can run hermetically) +15. **Sample payload:** Use `radiology/src/samples/requests/FullRequest-Example.json` as the canonical complete request, or compose from fragments (`PatientInformationRequest-Example.json`, `ReportRequest-Example.json`). The body in `SampleExtension.Radiology.Web.Quickstart.http` is the canonical reference. All patient data must remain fictional. + +## Hard rules + +- Single dependency-install step + single run command. Document both in the README. +- No real Entra ID tenant IDs, client IDs, secrets, or PHI anywhere — including in tests and comments. +- Do not invent fields, headers, or status codes that are not in the C# Quickstart. +- Do not modify the C# Quickstart or any shared models. +- Do not update the workflow index README; the partner adds the row themselves. + +## After generating + +1. Print a short summary: folder structure, key files, dependency-install command, run command. +2. Install dependencies for the new sample using the language's standard tooling. +3. Run the new sample's test suite. **Do not** start a live server for smoke testing — tests are sufficient. +4. If install or tests fail, fix the generated code and re-run before reporting success. +5. List anything the partner should review before committing (framework / library choices, port collision with the C# sample, anything the prompt was silent on). diff --git a/radiology/src/samples/Workflow/README.md b/radiology/src/samples/Workflow/README.md index d362aef..01fc154 100644 --- a/radiology/src/samples/Workflow/README.md +++ b/radiology/src/samples/Workflow/README.md @@ -13,7 +13,7 @@ partner extension pattern for Dragon Copilot. ## Solution -[`SampleExtensions.Radiology.Web.slnx`](./SampleExtensions.Radiology.Web.slnx) +[`SampleExtension.Radiology.Web.slnx`](./SampleExtension.Radiology.Web.slnx) contains the sample projects plus the shared [`Dragon.Copilot.Radiology.Models`](../../models/Dragon.Copilot.Radiology.Models/Dragon.Copilot.Radiology.Models.csproj) contract project. @@ -21,7 +21,7 @@ contract project. ## Build everything ```powershell -dotnet build SampleExtensions.Radiology.Web.slnx +dotnet build SampleExtension.Radiology.Web.slnx ``` ## Run a sample @@ -33,3 +33,17 @@ dotnet run --project SampleExtension.Radiology.Web.Quickstart # AI-backed (Azure OpenAI + Foundry Local fallback) dotnet run --project SampleExtension.Radiology.Web.Ai ``` + +## Samples in other languages + +These samples are written in C#, but the same wire contract works in any +language. To scaffold an equivalent sample in Python, Go, Java, Node.js, +TypeScript, or Rust, use the reusable Copilot prompt: + +1. Open the repo in VS Code with GitHub Copilot Chat enabled. +2. In Copilot Chat, type `/` and select `radiology-scaffold-language-sample`. +3. Provide the target language when prompted, then review and run the generated sample. + +The prompt lives at +[`.github/prompts/radiology-scaffold-language-sample.prompt.md`](../../../../.github/prompts/radiology-scaffold-language-sample.prompt.md) +and mirrors the C# Quickstart's contract, structure, and tests. diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Program.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Program.cs index 8e9b201..75b3503 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Program.cs +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Program.cs @@ -1,6 +1,7 @@ // Minimal, self-contained Radiology extension sample. // Partners can copy this project folder and run it with `dotnet run`. +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using SampleExtension.Radiology.Web.Ai.Configuration; using SampleExtension.Radiology.Web.Ai.Extensions; using SampleExtension.Radiology.Web.Ai.Services; @@ -67,8 +68,19 @@ }); } -app.MapHealthChecks("/health/liveness"); -app.MapHealthChecks("/health/readiness"); +// Health probes return a JSON body (e.g. {"status":"Healthy"}) so monitoring +// tools can parse the result rather than reading a plain-text string. +var healthCheckOptions = new HealthCheckOptions +{ + ResponseWriter = async (context, report) => + { + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(new { status = report.Status.ToString() }).ConfigureAwait(false); + }, +}; + +app.MapHealthChecks("/health/liveness", healthCheckOptions); +app.MapHealthChecks("/health/readiness", healthCheckOptions); // Apply JWT authentication to all non-public routes app.UseFullSecurity(); diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/README.md b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/README.md index 0ecda93..2e8eaf6 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/README.md +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/README.md @@ -14,7 +14,16 @@ that follows the expected contract. - JWT authentication via Microsoft Entra ID, toggleable via `Authentication.Enabled` in `appsettings.json` - AI-powered quality checks via Azure OpenAI **or** an on-device model through Microsoft.AI.Foundry.Local - Swagger UI at the app root in Development -- Health probes at `/health/liveness` and `/health/readiness` +- Health probes at `/health/liveness` and `/health/readiness` (JSON responses) + +## API endpoints + +| Method | Route | Auth | Description | +| ------ | --------------------- | ----------- | ----------------------------------------------------- | +| POST | `/v1/process` | JWT | Analyzes a radiology report, returns quality checks | +| GET | `/health/liveness` | Public | Liveness probe, returns `{"status":"Healthy"}` | +| GET | `/health/readiness` | Public | Readiness probe, returns `{"status":"Healthy"}` | +| GET | `/` | Public | Swagger UI (Development only) | ## Run locally diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Controllers/QualityCheckController.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Controllers/QualityCheckController.cs index cc49c4d..7251152 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Controllers/QualityCheckController.cs +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Controllers/QualityCheckController.cs @@ -34,13 +34,13 @@ public QualityCheckController(IQualityCheckService qualityCheckService, ILogger< /// /// /// This sample returns stubbed data loaded from MockData/qualitycheck-response.json. - /// Replace with your real implementation. + /// Replace with your real implementation. /// [HttpPost("process")] [ProducesResponseType(typeof(ProcessResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public ActionResult Post([FromBody] ProcessRequest payload) + public async Task> PostAsync([FromBody] ProcessRequest payload, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(payload); @@ -55,7 +55,7 @@ public ActionResult Post([FromBody] ProcessRequest payload) Request.Path, payload.SessionData.CorrelationId); - var result = _qualityCheckService.Process(payload); + var result = await _qualityCheckService.ProcessAsync(payload, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "Response {Method} {Path} - Success: {Success} - Message: {Message} - Response Body: {ResponseBody}", diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Program.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Program.cs index a4a3d67..a961667 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Program.cs +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Program.cs @@ -1,6 +1,7 @@ // Minimal, self-contained Radiology extension sample. // Partners can copy this project folder and run it with `dotnet run`. +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using SampleExtension.Radiology.Web.Quickstart.Extensions; using SampleExtension.Radiology.Web.Quickstart.Services; using System.Text.Json.Serialization; @@ -58,8 +59,19 @@ }); } -app.MapHealthChecks("/health/liveness"); -app.MapHealthChecks("/health/readiness"); +// Health probes return a JSON body (e.g. {"status":"Healthy"}) so monitoring +// tools can parse the result rather than reading a plain-text string. +var healthCheckOptions = new HealthCheckOptions +{ + ResponseWriter = async (context, report) => + { + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(new { status = report.Status.ToString() }).ConfigureAwait(false); + }, +}; + +app.MapHealthChecks("/health/liveness", healthCheckOptions); +app.MapHealthChecks("/health/readiness", healthCheckOptions); // Apply JWT authentication to all non-public routes app.UseFullSecurity(); diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/README.md b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/README.md index 43defaf..cb55f4a 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/README.md +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/README.md @@ -11,7 +11,16 @@ your own implementation and deploy a working extension that follows the expected - JWT authentication via Microsoft Entra ID, toggleable via `Authentication.Enabled` in `appsettings.json` - Stubbed responses loaded from JSON files under `MockData/` - Swagger UI at the app root in Development -- Health probes at `/health/liveness` and `/health/readiness` +- Health probes at `/health/liveness` and `/health/readiness` (JSON responses) + +## API endpoints + +| Method | Route | Auth | Description | +| ------ | --------------------- | ----------- | ----------------------------------------------------- | +| POST | `/v1/process` | JWT | Analyzes a radiology report, returns quality checks | +| GET | `/health/liveness` | Public | Liveness probe, returns `{"status":"Healthy"}` | +| GET | `/health/readiness` | Public | Readiness probe, returns `{"status":"Healthy"}` | +| GET | `/` | Public | Swagger UI (Development only) | ## Run locally @@ -27,6 +36,36 @@ Available endpoints: A `.http` file (`SampleExtension.Radiology.Web.Quickstart.http`) is included for sending sample requests from Visual Studio or VS Code. +## Testing the API + +Use the included `.http` file, or call the endpoints directly. + +**Health probes:** + +```bash +curl http://localhost:5080/health/liveness +curl http://localhost:5080/health/readiness +``` + +```powershell +Invoke-RestMethod http://localhost:5080/health/liveness +Invoke-RestMethod http://localhost:5080/health/readiness +``` + +**Process a report** (see [`SampleExtension.Radiology.Web.Quickstart.http`](./SampleExtension.Radiology.Web.Quickstart.http) for the full body). The relative path below assumes you are in the `radiology/src/samples/Workflow` directory (the same place you ran `dotnet run` from): + +```bash +curl -X POST http://localhost:5080/v1/process \ + -H "Content-Type: application/json" \ + -d '@../requests/FullRequest-Example.json' +``` + +```powershell +Invoke-RestMethod -Method Post -Uri http://localhost:5080/v1/process ` + -ContentType "application/json" ` + -InFile ..\requests\FullRequest-Example.json +``` + ## Security The application validates JWT bearer tokens on all routes except health probes. @@ -101,7 +140,7 @@ modifying any C# code. To replace the stub with real logic, edit [`Services/QualityCheckService.cs`](./Services/QualityCheckService.cs) — the -`IQualityCheckService.Process` method is the single integration point. +`IQualityCheckService.ProcessAsync` method is the single integration point. ## Request / response contract diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/IQualityCheckService.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/IQualityCheckService.cs index 3c8f2f5..f1580e9 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/IQualityCheckService.cs +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/IQualityCheckService.cs @@ -9,5 +9,5 @@ namespace SampleExtension.Radiology.Web.Quickstart.Services; /// public interface IQualityCheckService { - ProcessResponse Process(ProcessRequest payload); + Task ProcessAsync(ProcessRequest payload, CancellationToken cancellationToken = default); } diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/QualityCheckService.cs b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/QualityCheckService.cs index 4404482..6844fb1 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/QualityCheckService.cs +++ b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Services/QualityCheckService.cs @@ -39,7 +39,7 @@ public QualityCheckService( } /// - public ProcessResponse Process(ProcessRequest payload) + public Task ProcessAsync(ProcessRequest payload, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(payload); @@ -49,7 +49,7 @@ public ProcessResponse Process(ProcessRequest payload) payload.Report?.ReportText.Length); _logger.LogInformation("No model provider configured. Returning mock data."); - return ProcessWithMockData(); + return Task.FromResult(ProcessWithMockData()); } private ProcessResponse ProcessWithMockData() diff --git a/radiology/src/samples/Workflow/SampleExtensions.Radiology.Web.slnx b/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.slnx similarity index 100% rename from radiology/src/samples/Workflow/SampleExtensions.Radiology.Web.slnx rename to radiology/src/samples/Workflow/SampleExtension.Radiology.Web.slnx diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/.env.example b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/.env.example new file mode 100644 index 0000000..e0c1ff8 --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/.env.example @@ -0,0 +1,21 @@ +# Example environment configuration for the Radiology Python Quickstart. +# +# Settings are read from environment variables prefixed with DCR_RAD_ (and from +# this .env file). Nested keys use a double-underscore delimiter. +# +# Authentication is DISABLED by default for local development. Do NOT commit +# real tenant IDs, client IDs, or secrets. Copy this file to a local, untracked +# .env and fill in your own values when you harden for production. + +# Enable Microsoft Entra ID JWT validation on /v1/process. +# DCR_RAD_AUTHENTICATION__ENABLED=true + +# Your Entra ID tenant ID (GUID). +# DCR_RAD_AUTHENTICATION__TENANT_ID= + +# Your app registration's client ID — the expected token audience (GUID). +# DCR_RAD_AUTHENTICATION__CLIENT_ID= + +# Allowed caller (azp) client IDs — the Dragon Copilot Extensions Runtime app. +# Provide as a JSON array. +# DCR_RAD_AUTHENTICATION__REQUIRED_CLAIMS__AZP=[""] diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/.gitignore b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/.gitignore new file mode 100644 index 0000000..b06c0cc --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/.gitignore @@ -0,0 +1,12 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ + +# Virtual environments +.venv/ +venv/ + +# Local environment overrides (never commit real secrets) +.env diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/MockData/qualitycheck_response.json b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/MockData/qualitycheck_response.json new file mode 100644 index 0000000..4282b43 --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/MockData/qualitycheck_response.json @@ -0,0 +1,49 @@ +{ + "success": true, + "message": "Payload processed successfully.", + "payload": { + "qualityCheckResult": { + "recommendations": [ + { + "qualityCheckType": "Clinical", + "description": "Replace 'paddock steatosis' with 'hepatic steatosis'.", + "reason": "'Paddock steatosis' is not a recognized medical term and is a well-known speech-to-text mis-hearing of 'hepatic steatosis' (fatty liver). Leaving the erroneous term in the final report can mislead downstream clinicians, omit a clinically significant finding from the patient's problem list, and break automated coding and decision-support tools that key off standard terminology.", + "severityScorePercent": 85, + "provenance": [ + { + "text": "paddock steatosis", + "startPosition": 42, + "endPosition": 59 + } + ] + }, + { + "qualityCheckType": "Clinical", + "description": "Replace 'for views' with '4 views' on the chest radiograph.", + "reason": "'For views' is a phonetic mis-hearing of '4 views'. The number of projections obtained is part of the radiographic technique and must be documented accurately so the interpreting radiologist and downstream clinicians know the exam was a complete 4-view chest series rather than a limited study. An incorrect view count can change how findings are weighed and may lead to unnecessary repeat imaging.", + "severityScorePercent": 50, + "provenance": [ + { + "text": "for views", + "startPosition": 120, + "endPosition": 129 + } + ] + }, + { + "qualityCheckType": "Billing", + "description": "Document whether IV contrast was actually administered for the CT abdomen study.", + "reason": "The study is titled 'CT ABDOMEN WITH CONTRAST', but the body of the report does not explicitly state that intravenous contrast was administered, the contrast agent and volume used, or any pre-scan renal function or allergy screening. Accurate CPT selection depends on this distinction: 74150 (CT abdomen without contrast), 74160 (with contrast), and 74170 (without and with contrast). Missing documentation can lead to downcoding, payer denials, or compliance findings on audit. Confirm contrast administration with the technologist and update the technique section before finalizing the report.", + "severityScorePercent": 65, + "provenance": [ + { + "text": "CT ABDOMEN WITH CONTRAST", + "startPosition": 0, + "endPosition": 24 + } + ] + } + ] + } + } +} diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/README.md b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/README.md new file mode 100644 index 0000000..bc569e2 --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/README.md @@ -0,0 +1,230 @@ +# Partner Extension Sample — Radiology Quickstart (Python) + +A minimal radiology extension for Dragon Copilot, written in **Python** with +**FastAPI**. It returns a canned quality-check response loaded from a JSON file +on disk. Use it as the starting point for a new partner extension: replace the +stubbed logic with your own implementation and deploy a working extension that +follows the expected contract. + +This sample implements the **same wire contract** as the +[C# Quickstart](../SampleExtension.Radiology.Web.Quickstart/README.md). + +## What's included + +- FastAPI app (Python 3.12), single endpoint: `POST /v1/process` +- JWT authentication via Microsoft Entra ID (using [PyJWT](https://pyjwt.readthedocs.io/)), + toggleable via the `DCR_RAD_AUTHENTICATION__ENABLED` setting (off by default) +- Stubbed responses loaded from JSON files under `MockData/` +- Swagger UI at the app root (`/` redirects to FastAPI's built-in `/docs`) +- Health probes at `/health/liveness` and `/health/readiness` (JSON responses) +- A `pytest` test suite under `app/tests/` + +## API endpoints + +| Method | Route | Auth | Description | +| ------ | --------------------- | ----------- | ----------------------------------------------------- | +| POST | `/v1/process` | JWT | Analyzes a radiology report, returns quality checks | +| GET | `/health/liveness` | Public | Liveness probe, returns `{"status":"Healthy"}` | +| GET | `/health/readiness` | Public | Readiness probe, returns `{"status":"Healthy"}` | +| GET | `/` | Public | Swagger UI (redirects to `/docs`) | + +## Run locally + +The service binds to **HTTP port 5080** by default. Override the port by +changing the `--port` value in the run command. (Port 5080 collides with the C# +Radiology sample, so don't run both on the same host without changing one.) + +### Linux / macOS + +```bash +python3.12 -m venv .venv && source .venv/bin/activate && python3.12 -m pip install --upgrade pip && python3.12 -m pip install -r requirements.txt +python3.12 -m uvicorn app.main:app --host 0.0.0.0 --port 5080 --reload +``` + +### Windows (PowerShell) + +```powershell +python3.12 -m venv .venv +. .\.venv\Scripts\Activate.ps1 +python3.12 -m pip install --upgrade pip +python3.12 -m pip install -r requirements.txt +python3.12 -m uvicorn app.main:app --host 0.0.0.0 --port 5080 --reload +``` + +Available endpoints: + +- Swagger UI: http://localhost:5080/ (redirects to `/docs`) +- Health: `/health/liveness`, `/health/readiness` + +## Testing the API + +### Health probes + +```bash +curl http://localhost:5080/health/liveness +curl http://localhost:5080/health/readiness +``` + +```powershell +Invoke-RestMethod http://localhost:5080/health/liveness +Invoke-RestMethod http://localhost:5080/health/readiness +``` + +### Process a report + +The relative path below assumes you are in the +`radiology/src/samples/Workflow` directory. + +```bash +curl -X POST http://localhost:5080/v1/process \ + -H "Content-Type: application/json" \ + -d '@../requests/FullRequest-Example.json' +``` + +```powershell +Invoke-RestMethod -Method Post -Uri http://localhost:5080/v1/process ` + -ContentType "application/json" ` + -InFile ..\requests\FullRequest-Example.json +``` + +## Running the tests + +From the sample root (`sample_extension_radiology_python_quickstart`), after +installing dependencies: + +```bash +python3.12 -m pytest +``` + +```powershell +python3.12 -m pytest +``` + +The suite covers the health probes, the `/v1/process` happy path against the +canonical sample payload, the framework's default validation error for missing +required fields, and the authentication toggle (401 when enabled without a +token; 200 with a valid bearer token, verified hermetically with a locally +generated signing key). + +## Security + +The application validates JWT bearer tokens on `/v1/process` when +authentication is enabled. Health probes are always public. + +> **CORS:** This sample enables fully open CORS for easy local testing. **Lock +> this down to specific origins, methods, and headers before deploying to +> production.** + +### JWT Authentication (Microsoft Entra ID) + +When enabled, every request to `/v1/process` must carry a valid Bearer token +issued by the configured Entra ID tenant. The token is validated for: + +- **Signature** against the tenant's Entra ID JWKS keys +- **Issuer** matching `https://login.microsoftonline.com//v2.0` + (the v1.0 `https://sts.windows.net//` issuer is also accepted) +- **Audience** matching the configured client ID +- At least one `azp` / `appid` claim in the allowed-caller list + +Requests with a missing, expired, or invalid token receive **401 Unauthorized**. + +This sample uses **[PyJWT](https://pyjwt.readthedocs.io/)** (with the `crypto` +extra) for signature validation and JWKS key resolution. + +### Configuration + +Settings are read from environment variables prefixed with `DCR_RAD_` (and an +optional `.env` file). Nested keys use a double-underscore delimiter. These +mirror the `Authentication` section of the C# sample's `appsettings.json`. + +| Environment variable | Description | +| --------------------------------------------------- | ------------------------------------------ | +| `DCR_RAD_AUTHENTICATION__ENABLED` | Enable or disable authentication | +| `DCR_RAD_AUTHENTICATION__TENANT_ID` | Your Entra ID tenant ID | +| `DCR_RAD_AUTHENTICATION__CLIENT_ID` | Your app registration's client ID | +| `DCR_RAD_AUTHENTICATION__INSTANCE` | Login endpoint | +| `DCR_RAD_AUTHENTICATION__REQUIRED_CLAIMS__AZP` | Allowed caller client IDs (JSON array) | + +See [`.env.example`](./.env.example) for a template. + +### Local development + +Authentication is **disabled by default** so the API can be called without +tokens. When disabled, all routes are anonymous. + +### Enabling security for production + +1. Register an application in [Microsoft Entra ID](https://entra.microsoft.com/). +2. Set the following (for example in a local, untracked `.env`): + + ```bash + DCR_RAD_AUTHENTICATION__ENABLED=true + DCR_RAD_AUTHENTICATION__TENANT_ID= + DCR_RAD_AUTHENTICATION__CLIENT_ID= + DCR_RAD_AUTHENTICATION__REQUIRED_CLAIMS__AZP=[""] + ``` + +Once enabled, callers must include the bearer token on every request: + +``` +Authorization: Bearer +``` + +> Never commit real tenant IDs, client IDs, or secrets. + +## Quality check provider + +This Quickstart sample always returns the canned response in +[`MockData/qualitycheck_response.json`](./MockData/qualitycheck_response.json). +Edit the JSON directly to tweak the stubbed output without changing any Python +code. + +To replace the stub with real logic, edit +[`app/service.py`](./app/service.py) — the +`QualityCheckService.process_async` method is the single integration point. + +## Request / response contract + +See [`radiology-extensibility-api.yaml`](../../../radiology-extensibility-api.yaml) +for the full OpenAPI spec. + +Only `sessionData` is required. `extensibilityApiVersion` shows which Dragon +Copilot API version sent the request, and your extension does not need to read +it. Extra fields are accepted, so your extension keeps working as the API +evolves. + +**`POST /v1/process`** with `application/json` + +```jsonc +{ + "extensibilityApiVersion": "1.1.1", + "sessionData": { + "correlation_id": "abc-123", + "session_start": "2025-01-01T10:00:00Z", + "environment_id": "env-456" + }, + "patientInformation": { + "dateOfBirth": "1980-05-12", + "biologicalSex": "Female" + }, + "report": { + "reportText": "CT ABDOMEN … paddock steatosis …" + } +} +``` + +Response: + +```jsonc +{ + "success": true, + "message": "Payload processed successfully.", + "payload": { + "qualityCheckResult": { + "recommendations": [ + /* ... */ + ] + } + } +} +``` diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/__init__.py b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/auth.py b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/auth.py new file mode 100644 index 0000000..6f8c991 --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/auth.py @@ -0,0 +1,137 @@ +"""Microsoft Entra ID JWT bearer validation. + +Mirrors the C# Quickstart's JWT authentication. Validation checks: + +* Signature against the Entra ID JWKS for the configured tenant. +* Issuer matches ``https://login.microsoftonline.com//v2.0`` (or the + v1.0 ``https://sts.windows.net//`` equivalent). +* Audience matches the configured client ID. +* At least one of the ``azp`` / ``appid`` claims is in the allowed-caller list. + +Authentication is toggled by ``Authentication.Enabled`` (off by default). When +off, the FastAPI dependency is a no-op and every route is anonymous. + +A well-known, idiomatic library (`PyJWT`, with the ``crypto`` extra) performs +signature validation. The signing-key resolver is injectable so tests can +supply a local key and run hermetically without contacting Entra ID. +""" + +from __future__ import annotations + +from typing import Protocol + +import jwt +from fastapi import Depends, Header, HTTPException, status + +from .config import AuthenticationSettings, Settings, get_settings + + +class SigningKeyResolver(Protocol): + """Resolves the public signing key for a given bearer token.""" + + def get_signing_key(self, token: str) -> object: # pragma: no cover - protocol + ... + + +class JwksSigningKeyResolver: + """Resolves signing keys from the tenant's Entra ID JWKS endpoint.""" + + def __init__(self, jwks_uri: str) -> None: + # PyJWKClient caches keys and refreshes on cache miss. + self._client = jwt.PyJWKClient(jwks_uri) + + def get_signing_key(self, token: str) -> object: + return self._client.get_signing_key_from_jwt(token).key + + +# Module-level resolver cache keyed by JWKS URI so we reuse one PyJWKClient. +_resolvers: dict[str, SigningKeyResolver] = {} + + +def _default_resolver(auth: AuthenticationSettings) -> SigningKeyResolver: + resolver = _resolvers.get(auth.jwks_uri) + if resolver is None: + resolver = JwksSigningKeyResolver(auth.jwks_uri) + _resolvers[auth.jwks_uri] = resolver + return resolver + + +# Overridable hook so tests can inject a hermetic signing-key resolver. +_resolver_factory = _default_resolver + + +def set_signing_key_resolver_factory(factory) -> None: + """Override the signing-key resolver factory (used by tests).""" + + global _resolver_factory + _resolver_factory = factory + + +def reset_signing_key_resolver_factory() -> None: + """Restore the default JWKS-backed resolver factory.""" + + global _resolver_factory + _resolver_factory = _default_resolver + + +def _unauthorized(detail: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def validate_token(token: str, auth: AuthenticationSettings) -> dict: + """Validate a bearer token, returning its claims or raising 401.""" + + resolver = _resolver_factory(auth) + try: + signing_key = resolver.get_signing_key(token) + except Exception as exc: # noqa: BLE001 - any key-resolution failure is a 401 + raise _unauthorized("Unable to resolve token signing key.") from exc + + try: + claims = jwt.decode( + token, + signing_key, + algorithms=["RS256"], + audience=auth.client_id, + issuer=auth.valid_issuers, + options={"require": ["exp", "iss", "aud"]}, + ) + except jwt.PyJWTError as exc: + raise _unauthorized("Invalid token.") from exc + + # idtyp (when present) must indicate an application token. + idtyp = claims.get("idtyp") + if idtyp is not None and idtyp not in auth.required_claims.idtyp: + raise _unauthorized("Token identity type is not permitted.") + + # At least one of azp / appid must be an allowed caller. + caller = claims.get("azp") or claims.get("appid") + if caller not in auth.required_claims.azp: + raise _unauthorized("Caller is not in the allowed-caller list.") + + return claims + + +async def require_auth( + authorization: str | None = Header(default=None), + settings: Settings = Depends(get_settings), +) -> dict | None: + """FastAPI dependency enforcing Entra ID auth when enabled. + + Returns ``None`` when authentication is disabled (anonymous access). When + enabled, returns the validated token claims or raises ``401``. + """ + + auth = settings.authentication + if not auth.enabled: + return None + + if not authorization or not authorization.lower().startswith("bearer "): + raise _unauthorized("Missing bearer token.") + + token = authorization[len("Bearer ") :].strip() + return validate_token(token, auth) diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/config.py b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/config.py new file mode 100644 index 0000000..b219807 --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/config.py @@ -0,0 +1,85 @@ +"""Application configuration via pydantic-settings. + +Settings are read from environment variables prefixed with ``DCR_RAD_`` and an +optional ``.env`` file. Nested values use a double-underscore delimiter, e.g. +``DCR_RAD_AUTHENTICATION__ENABLED=true``. + +The ``Authentication`` section mirrors the keys in the C# Quickstart's +``appsettings.json``. Authentication is disabled by default for local +development; partners enable it intentionally when hardening for production. +""" + +from __future__ import annotations + +from functools import lru_cache + +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class RequiredClaims(BaseModel): + """Claims an inbound token must satisfy (mirrors C# RequiredClaims).""" + + # Token identity type must be "app" (application token, not a user token). + idtyp: list[str] = Field(default_factory=lambda: ["app"]) + # Allowed authorized-party (azp) client IDs — the Dragon Copilot Extensions + # Runtime application(s) permitted to call this extension. + azp: list[str] = Field(default_factory=lambda: [""]) + + +class AuthenticationSettings(BaseModel): + """Microsoft Entra ID JWT validation settings (mirrors C# Authentication).""" + + enabled: bool = False + tenant_id: str = Field(default="", alias="TenantId") + client_id: str = Field(default="", alias="ClientId") + instance: str = Field(default="https://login.microsoftonline.com/", alias="Instance") + required_claims: RequiredClaims = Field( + default_factory=RequiredClaims, alias="RequiredClaims" + ) + + model_config = SettingsConfigDict(populate_by_name=True) + + @property + def authority(self) -> str: + """The Entra ID authority base URL for the configured tenant.""" + + return f"{self.instance.rstrip('/')}/{self.tenant_id}" + + @property + def jwks_uri(self) -> str: + """The JWKS endpoint advertised by the v2.0 OpenID metadata.""" + + return f"{self.authority}/discovery/v2.0/keys" + + @property + def valid_issuers(self) -> list[str]: + """Accepted token issuers (v2.0 and v1.0 endpoints).""" + + return [ + f"{self.authority}/v2.0", + f"https://sts.windows.net/{self.tenant_id}/", + ] + + +class Settings(BaseSettings): + """Top-level application settings.""" + + model_config = SettingsConfigDict( + env_prefix="DCR_RAD_", + env_nested_delimiter="__", + env_file=".env", + extra="ignore", + ) + + app_name: str = "Sample Radiology Extension (Python)" + version: str = "0.0.1" + mock_data_file: str = "MockData/qualitycheck_response.json" + authentication: AuthenticationSettings = Field(default_factory=AuthenticationSettings) + + +@lru_cache +def get_settings() -> Settings: + """Return a cached Settings instance.""" + + return Settings() diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/main.py b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/main.py new file mode 100644 index 0000000..cbf7ec7 --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/main.py @@ -0,0 +1,99 @@ +"""FastAPI application for the Radiology quality-check sample extension. + +Exposes ``POST /v1/process`` plus liveness/readiness probes, mirroring the C# +Quickstart. CORS is fully open for local testing (lock this down in +production). Swagger UI is served by FastAPI at ``/docs``; ``GET /`` redirects +there. +""" + +from __future__ import annotations + +import logging + +from fastapi import Depends, FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, RedirectResponse + +from .auth import require_auth +from .config import get_settings +from .models import ProcessRequest, ProcessResponse, serialize_response +from .service import QualityCheckService + +logger = logging.getLogger("dragon.radiology.pyextension") +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] %(levelname)s %(name)s - %(message)s", +) + +settings = get_settings() +service = QualityCheckService(settings) + +app = FastAPI( + title="Simple Radiology Extension API", + version=settings.version, + description=( + "A simple radiology extension sample that demonstrates the extension " + "pattern for Dragon Copilot." + ), +) + +# CORS is fully open here for easy local testing. +# WARNING: restrict allowed origins, methods, and headers for production. +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +def _health_payload() -> dict[str, str]: + return {"status": "Healthy"} + + +@app.get("/", include_in_schema=False) +async def root_redirect() -> RedirectResponse: + """Redirect the root to the bundled Swagger UI (mirrors the C# sample).""" + + return RedirectResponse(url="/docs") + + +@app.get("/health/liveness", tags=["health"]) +async def liveness() -> dict[str, str]: + """Liveness probe. Returns ``{"status": "Healthy"}``.""" + + return _health_payload() + + +@app.get("/health/readiness", tags=["health"]) +async def readiness() -> JSONResponse: + """Readiness probe. Healthy only when the canned mock data is present.""" + + if service.mock_data_exists(): + return JSONResponse(status_code=200, content=_health_payload()) + return JSONResponse(status_code=503, content={"status": "Unhealthy"}) + + +@app.post("/v1/process", response_model=ProcessResponse) +async def process( + payload: ProcessRequest, + _claims: dict | None = Depends(require_auth), +) -> JSONResponse: + """Analyze a radiology report and return quality-check recommendations. + + This sample returns stubbed data loaded from + ``MockData/qualitycheck_response.json``. Replace + :meth:`QualityCheckService.process_async` with your real implementation. + """ + + logger.info( + "Received POST /v1/process - correlation_id=%s", + payload.session_data.correlation_id, + ) + result = await service.process_async(payload) + logger.info( + "Response POST /v1/process - success=%s message=%s", + result.success, + result.message, + ) + return JSONResponse(status_code=200, content=serialize_response(result)) diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/models.py b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/models.py new file mode 100644 index 0000000..63a77ee --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/models.py @@ -0,0 +1,146 @@ +"""Pydantic mirrors of the C# Dragon.Copilot.Radiology.Models wire types. + +Internal Python attributes use snake_case (idiomatic), while the JSON wire +contract uses the casing defined in radiology-extensibility-api.yaml. The +mapping is expressed with pydantic ``alias`` values, mirroring the +``[JsonPropertyName]`` attributes on the C# models: + +* Top-level envelope and ``PatientInformation`` / ``Report`` -> camelCase. +* ``SessionData`` -> snake_case (inherited from the upstream Dragon contract). + +``populate_by_name=True`` lets tests and internal code construct models with +either the Python attribute name or the wire alias. +""" + +from __future__ import annotations + +from datetime import date, datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class _WireModel(BaseModel): + """Base model: serialize by alias, accept either alias or field name.""" + + model_config = ConfigDict(populate_by_name=True) + + +class BiologicalSex(str, Enum): + """Biological sex of the patient (mirrors C# BiologicalSex enum).""" + + Male = "Male" + Female = "Female" + Unknown = "Unknown" + Other = "Other" + + +class QualityCheckType(str, Enum): + """The type of quality check (mirrors C# QualityCheckType enum).""" + + Billing = "Billing" + Clinical = "Clinical" + + +class SessionData(_WireModel): + """Session context for request correlation and tracking. + + JSON property names on this type are snake_case by design, inherited from + the upstream Dragon SessionData contract. + """ + + correlation_id: str | None = Field(default=None, alias="correlation_id") + session_start: datetime | None = Field(default=None, alias="session_start") + environment_id: str | None = Field(default=None, alias="environment_id") + + +class PatientInformation(_WireModel): + """Patient demographic information.""" + + date_of_birth: date | None = Field(default=None, alias="dateOfBirth") + biological_sex: BiologicalSex | None = Field(default=None, alias="biologicalSex") + + +class Report(_WireModel): + """Radiology report text payload.""" + + report_text: str = Field(alias="reportText") + + +class Provenance(_WireModel): + """Identifies a section in the report used to generate a recommendation.""" + + text: str | None = None + start_position: float | None = Field(default=None, alias="startPosition") + end_position: float | None = Field(default=None, alias="endPosition") + + +class ReferenceResource(_WireModel): + """A reference resource that helps understand the recommendation.""" + + type: str | None = None + content: str | None = None + + +class Recommendation(_WireModel): + """A quality-check recommendation produced by an extension.""" + + quality_check_type: QualityCheckType = Field(alias="qualityCheckType") + description: str + reason: str + severity_score_percent: float | None = Field( + default=None, alias="severityScorePercent" + ) + provenance: list[Provenance] | None = None + reference_resources: list[ReferenceResource] | None = Field( + default=None, alias="referenceResources" + ) + additional_info: dict[str, str] | None = Field( + default=None, alias="additionalInfo" + ) + + +class QualityCheckResult(_WireModel): + """Quality-check result payload containing billing and clinical findings.""" + + recommendations: list[Recommendation] = Field(default_factory=list) + + +class ProcessRequest(_WireModel): + """Request envelope for the /v1/process endpoint. + + Only ``session_data`` is required. ``additionalProperties: true`` in the + OpenAPI schema means partner payloads may carry extra named inputs; pydantic + ignores unknown keys by default, so the extension keeps working as the API + evolves. + """ + + extensibility_api_version: str | None = Field( + default=None, alias="extensibilityApiVersion" + ) + session_data: SessionData = Field(alias="sessionData") + patient_information: PatientInformation | None = Field( + default=None, alias="patientInformation" + ) + report: Report | None = None + + +class ProcessResponse(_WireModel): + """Response envelope for the /v1/process endpoint. + + ``payload`` is a map of named outputs (e.g. ``qualityCheckResult``), each + value being a :class:`QualityCheckResult`. Output names are declared in the + extension's manifest. There is intentionally no version field on the + response. + """ + + success: bool | None = None + message: str | None = None + payload: dict[str, QualityCheckResult] | None = None + + +def serialize_response(response: ProcessResponse) -> dict[str, Any]: + """Serialize a ProcessResponse to a wire-shaped dict (camelCase aliases).""" + + return response.model_dump(by_alias=True, exclude_none=True) diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/service.py b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/service.py new file mode 100644 index 0000000..c53cf57 --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/service.py @@ -0,0 +1,94 @@ +"""Quality-check service. + +Returns a stubbed response loaded from ``MockData/qualitycheck_response.json``. +This is the single integration point: partners replace +:meth:`QualityCheckService.process_async` with their real implementation. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path + +from .config import Settings, get_settings +from .models import ProcessRequest, ProcessResponse, QualityCheckResult + +logger = logging.getLogger("dragon.radiology.pyextension") + +_QUALITY_CHECK_PAYLOAD_KEY = "qualityCheckResult" + + +class QualityCheckService: + """Loads canned quality-check data and returns it for any request.""" + + def __init__(self, settings: Settings | None = None) -> None: + self._settings = settings or get_settings() + # MockData lives at the sample root, next to the ``app`` package. + sample_root = Path(__file__).resolve().parents[1] + self._mock_data_path = sample_root / self._settings.mock_data_file + self._mock_response: ProcessResponse | None = None + + def mock_data_exists(self) -> bool: + """Whether the canned mock-data file is present (readiness probe).""" + + return self._mock_data_path.is_file() + + async def process_async(self, payload: ProcessRequest) -> ProcessResponse: + """Run the quality check for an incoming request. + + The only logic here is returning the canned mock data. Partners replace + this method with their real implementation. + """ + + report_length = len(payload.report.report_text) if payload.report else 0 + logger.info( + "Running quality check on radiology request. correlation_id=%s report_length=%s", + payload.session_data.correlation_id, + report_length, + ) + logger.info("No model provider configured. Returning mock data.") + return self._process_with_mock_data() + + def _process_with_mock_data(self) -> ProcessResponse: + template = self._load_mock_response() + result = ProcessResponse( + success=template.success, + message=template.message, + payload={}, + ) + + if template.payload and _QUALITY_CHECK_PAYLOAD_KEY in template.payload: + template_qc = template.payload[_QUALITY_CHECK_PAYLOAD_KEY] + result.payload[_QUALITY_CHECK_PAYLOAD_KEY] = QualityCheckResult( + recommendations=list(template_qc.recommendations) + ) + + return result + + def _load_mock_response(self) -> ProcessResponse: + if self._mock_response is not None: + return self._mock_response + + if not self._mock_data_path.is_file(): + logger.warning( + "Mock data file not found at %s. Returning an empty successful response.", + self._mock_data_path, + ) + self._mock_response = ProcessResponse( + success=True, message="No mock data configured." + ) + return self._mock_response + + raw = json.loads(self._mock_data_path.read_text(encoding="utf-8")) + response = ProcessResponse.model_validate(raw) + count = ( + len(response.payload[_QUALITY_CHECK_PAYLOAD_KEY].recommendations) + if response.payload and _QUALITY_CHECK_PAYLOAD_KEY in response.payload + else 0 + ) + logger.info( + "Loaded %s mock recommendation(s) from %s.", count, self._mock_data_path + ) + self._mock_response = response + return self._mock_response diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/__init__.py b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/conftest.py b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/conftest.py new file mode 100644 index 0000000..57c69da --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/conftest.py @@ -0,0 +1,60 @@ +"""Shared pytest fixtures for the Radiology Python Quickstart tests.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +# Ensure the sample root (parent of the ``app`` package) is importable. +CURRENT_FILE = Path(__file__).resolve() +SAMPLE_ROOT = CURRENT_FILE.parents[2] +if str(SAMPLE_ROOT) not in sys.path: + sys.path.insert(0, str(SAMPLE_ROOT)) + +from app.config import get_settings # type: ignore # noqa: E402 +from app.main import app # type: ignore # noqa: E402 + + +@pytest.fixture() +def client() -> TestClient: + """FastAPI TestClient with cached settings cleared between tests.""" + + get_settings.cache_clear() + return TestClient(app) + + +@pytest.fixture() +def sample_request() -> dict: + """Canonical complete request body (radiology FullRequest example).""" + + return { + "extensibilityApiVersion": "1.1.1", + "sessionData": { + "correlation_id": "11111111-2222-3333-4444-555555555555", + "session_start": "2025-01-01T10:00:00Z", + "environment_id": "local-dev", + }, + "patientInformation": { + "dateOfBirth": "1980-05-12", + "biologicalSex": "Female", + }, + "report": { + "reportText": ( + "CT ABDOMEN WITH CONTRAST: The liver demonstrates paddock " + "steatosis. Chest X-ray performed with for views shows clear " + "lung fields." + ) + }, + } + + +@pytest.fixture() +def mock_response_json() -> dict: + """The canned mock-data file, parsed.""" + + mock_path = SAMPLE_ROOT / "MockData" / "qualitycheck_response.json" + return json.loads(mock_path.read_text(encoding="utf-8")) diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/test_auth.py b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/test_auth.py new file mode 100644 index 0000000..58c6657 --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/test_auth.py @@ -0,0 +1,96 @@ +"""Entra ID JWT authentication toggle tests. + +These run hermetically: a local RSA key pair signs the test token and an +injected signing-key resolver returns the matching public key, so no network +call to Entra ID is made. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi.testclient import TestClient + +from app import auth as auth_module +from app.config import ( + AuthenticationSettings, + RequiredClaims, + Settings, + get_settings, +) +from app.main import app + +_TENANT_ID = "11111111-1111-1111-1111-111111111111" +_CLIENT_ID = "22222222-2222-2222-2222-222222222222" +_CALLER_ID = "33333333-3333-3333-3333-333333333333" + + +@pytest.fixture() +def rsa_key_pair(): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key, private_key.public_key() + + +@pytest.fixture() +def auth_enabled_client(rsa_key_pair): + private_key, public_key = rsa_key_pair + + enabled_settings = Settings( + authentication=AuthenticationSettings( + enabled=True, + tenant_id=_TENANT_ID, + client_id=_CLIENT_ID, + required_claims=RequiredClaims(idtyp=["app"], azp=[_CALLER_ID]), + ) + ) + + app.dependency_overrides[get_settings] = lambda: enabled_settings + + class _StubResolver: + def get_signing_key(self, token: str): + return public_key + + auth_module.set_signing_key_resolver_factory(lambda auth: _StubResolver()) + + try: + yield TestClient(app), enabled_settings.authentication, private_key + finally: + app.dependency_overrides.pop(get_settings, None) + auth_module.reset_signing_key_resolver_factory() + + +def _make_token(private_key, auth: AuthenticationSettings) -> str: + now = datetime.now(tz=timezone.utc) + claims = { + "iss": f"{auth.authority}/v2.0", + "aud": auth.client_id, + "azp": _CALLER_ID, + "idtyp": "app", + "iat": now, + "exp": now + timedelta(hours=1), + } + return jwt.encode(claims, private_key, algorithm="RS256") + + +def test_enabled_without_token_returns_401(auth_enabled_client, sample_request): + client, _auth, _private_key = auth_enabled_client + response = client.post("/v1/process", json=sample_request) + assert response.status_code == 401 + + +def test_enabled_with_valid_token_returns_200(auth_enabled_client, sample_request): + client, auth, private_key = auth_enabled_client + token = _make_token(private_key, auth) + + response = client.post( + "/v1/process", + json=sample_request, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + recommendations = response.json()["payload"]["qualityCheckResult"]["recommendations"] + assert len(recommendations) == 3 diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/test_process.py b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/test_process.py new file mode 100644 index 0000000..53178c5 --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/app/tests/test_process.py @@ -0,0 +1,43 @@ +"""Health probe and /v1/process happy-path / validation tests.""" + +from __future__ import annotations + + +def test_liveness_returns_healthy(client): + response = client.get("/health/liveness") + assert response.status_code == 200 + assert response.json() == {"status": "Healthy"} + + +def test_readiness_returns_healthy_when_mock_data_present(client): + response = client.get("/health/readiness") + assert response.status_code == 200 + assert response.json() == {"status": "Healthy"} + + +def test_process_happy_path_returns_canned_response(client, sample_request): + response = client.post("/v1/process", json=sample_request) + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["message"] == "Payload processed successfully." + + recommendations = body["payload"]["qualityCheckResult"]["recommendations"] + assert len(recommendations) == 3 + + types = {rec["qualityCheckType"] for rec in recommendations} + assert "Clinical" in types + assert "Billing" in types + + paddock = next( + rec for rec in recommendations if "paddock steatosis" in rec["description"] + ) + assert paddock["severityScorePercent"] == 85 + + +def test_process_missing_required_fields_returns_validation_error(client): + # sessionData is required by the contract; omitting it triggers FastAPI's + # default 422 validation error. + response = client.post("/v1/process", json={}) + assert response.status_code == 422 diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/extension.yaml b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/extension.yaml new file mode 100644 index 0000000..1c81023 --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/extension.yaml @@ -0,0 +1,26 @@ +name: sampleQualityCheckExtension +description: Sample radiology quality check extension +version: 0.0.1 +radiologyExtensibilityApiVersion: 1.0.0 +auth: + tenantId: 00000000-0000-0000-0000-000000000000 +tools: + - name: sampleQualityCheckTool + toolType: contractBased + capability: qualityCheck + description: Tool to check quality of a radiology report + endpoint: http://localhost:5080/v1/process + inputs: + - name: report + description: Radiology report from Dragon Copilot + content-type: application/vnd.ms-dragon.rad.report+json + schemaVersion: "1.0" + - name: patientInformation + description: Patient demographic information + content-type: application/vnd.ms-dragon.rad.patient-information+json + schemaVersion: "1.0" + outputs: + - name: qualityCheckResult + description: Quality check findings and score + content-type: application/vnd.ms-dragon.rad.quality-check-result+json + schemaVersion: "1.0" diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/requirements.txt b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/requirements.txt new file mode 100644 index 0000000..8f28257 --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.116.1 +uvicorn==0.35.0 +pydantic==2.11.7 +pydantic-settings==2.10.1 +PyJWT[crypto]==2.10.1 +httpx==0.28.1 +pytest==9.0.3 diff --git a/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/sample_extension_radiology_python_quickstart.http b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/sample_extension_radiology_python_quickstart.http new file mode 100644 index 0000000..29900ba --- /dev/null +++ b/radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/sample_extension_radiology_python_quickstart.http @@ -0,0 +1,28 @@ +@host = http://localhost:5080 + +### Liveness probe +GET {{host}}/health/liveness + +### Readiness probe +GET {{host}}/health/readiness + +### Process a radiology report (happy path) +# @timeout 60 +POST {{host}}/v1/process +Content-Type: application/json + +{ + "extensibilityApiVersion": "1.1.1", + "sessionData": { + "correlation_id": "11111111-2222-3333-4444-555555555555", + "session_start": "2025-01-01T10:00:00Z", + "environment_id": "local-dev" + }, + "patientInformation": { + "dateOfBirth": "1980-05-12", + "biologicalSex": "Female" + }, + "report": { + "reportText": "CT ABDOMEN WITH CONTRAST: The liver demonstrates paddock steatosis. Chest X-ray performed with for views shows clear lung fields." + } +} From 60d103c8e3392cd28fbfc72532afba74788fcd24 Mon Sep 17 00:00:00 2001 From: Ashok Ginjala Date: Tue, 16 Jun 2026 19:39:34 -0400 Subject: [PATCH 4/7] Remove physician instructions; radiology PR only --- .../csharp-sample.instructions.md | 2 +- .../instructions/physician.instructions.md | 92 ------------------- 2 files changed, 1 insertion(+), 93 deletions(-) delete mode 100644 .github/instructions/physician.instructions.md diff --git a/.github/instructions/csharp-sample.instructions.md b/.github/instructions/csharp-sample.instructions.md index e4017a3..9b25073 100644 --- a/.github/instructions/csharp-sample.instructions.md +++ b/.github/instructions/csharp-sample.instructions.md @@ -56,4 +56,4 @@ The following patterns appear in some samples but are **not** universal across t - Source-generated `[LoggerMessage]` partials. - A per-sample `extension.yaml` manifest checked into the C# sample folder. -When working inside a specific product folder, also follow that product's overlay (e.g. `physician.instructions.md`, `radiology.instructions.md`). +When working inside a specific product folder, also follow that product's overlay (e.g. `radiology.instructions.md`). diff --git a/.github/instructions/physician.instructions.md b/.github/instructions/physician.instructions.md deleted file mode 100644 index 9b95cb3..0000000 --- a/.github/instructions/physician.instructions.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -applyTo: 'physician/**' ---- - -# Physician Extension Samples — Copilot Instructions - -> Physician owners own this file. Extend or refine it as your samples evolve. - -## Sample layout - -- C# sample: `physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/` -- Python sample: `physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/` -- Models project: `physician/src/models/Dragon.Copilot.Physician.Models/` -- OpenAPI contract: `physician/physician-extensibility-api.yaml` -- Quickstart guide: `physician/QUICKSTART.md` - -## Stack facts - -- C# sample targets **.NET 9.0**, ASP.NET Core Web API. -- Default dev ports: **5181** (HTTP), **7156** (HTTPS). -- Authentication is enabled by default in `appsettings.json` and can be toggled per environment. -- The C# sample includes a custom `LicenseKeyMiddleware` for subscription / feature-key validation in addition to JWT auth. - -## Core data models - -The Physician models project defines the canonical clinical types used by the extension contract. Key DTOs include: - -- `DragonStandardPayload` — top-level payload carrying session data and clinical context -- `Note` — clinical notes and documentation -- `Transcript` — speech-to-text transcriptions -- `IterativeTranscript` / `IterativeAudio` — real-time streaming data -- `Patient` / `Practitioner` — healthcare entities -- `Encounter` — clinical visits and sessions -- `MedicalCode` — standardized medical coding (ICD, SNOMED, etc.) - -## Service entry point - -The Physician sample exposes its business logic through `IProcessingService` (see `Services/IProcessingService.cs`). To replace the stubbed extraction logic with your own, edit `Services/ProcessingService.cs` — this is the single integration point. - -## Endpoint shape - -```csharp -[ApiController] -[Route("v1")] -public class ProcessController : ControllerBase -{ - [HttpPost("process")] - [Authorize(Policy = "RequiredClaims")] - public async Task> Process( - [FromBody] DragonStandardPayload payload, - CancellationToken cancellationToken = default) -} -``` - -## Authentication layers - -Physician samples use two layers stacked on top of each other: - -1. **JWT bearer authentication** via Microsoft Entra ID and Microsoft Identity Web. -2. **License key validation** via a custom middleware (`LicenseKeyMiddleware`) for business-level subscription and feature gating. - -Both layers can be disabled in development through `appsettings.Development.json`. - -## Manifest format (Physician) - -```yaml -name: extension-name -description: Extension description -version: 0.0.1 -auth: - tenantId: 00000000-0000-0000-0000-000000000000 -tools: - - name: tool-name - description: Tool description - endpoint: https://api.example.com/v1/process - trigger: AutoRun # AutoRun (default) or AdaptiveCardAction - inputs: - - name: note - description: Clinical note input - content-type: application/vnd.ms-dragon.dsp.note+json - outputs: - - name: processed-data - description: Processed results - content-type: application/vnd.ms-dragon.dsp+json -``` - -## Local development workflow - -1. `dotnet run --project physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web`. -2. Open Swagger at `http://localhost:5181/`. -3. Health probes at `/health/liveness` and `/health/readiness`. -4. Use the bundled `.http` files in the sample folder for quick request testing. From 20a0192f2f185bbf52e433e831603425843a7f09 Mon Sep 17 00:00:00 2001 From: Ashok Ginjala Date: Wed, 17 Jun 2026 20:44:41 -0400 Subject: [PATCH 5/7] Apply Dragon Copilot (radiologists) branding --- .../csharp-sample.instructions.md | 16 +-- .../python-sample.instructions.md | 28 ++--- .../instructions/radiologists.instructions.md | 115 ++++++++++++++++++ .../instructions/radiology.instructions.md | 115 ------------------ ...ogists-scaffold-language-sample.prompt.md} | 42 +++---- .../Directory.Packages.props | 0 radiologists/README.md | 42 +++++++ .../radiologists-extensibility-api.yaml | 12 +- .../BiologicalSex.cs | 2 +- .../Dragon.Copilot.Radiologists.Models.csproj | 0 .../PatientInformation.cs | 4 +- .../ProcessRequest.cs | 4 +- .../ProcessResponse.cs | 4 +- .../Provenance.cs | 4 +- .../QualityCheckResult.cs | 4 +- .../QualityCheckType.cs | 2 +- .../Recommendation.cs | 4 +- .../ReferenceResource.cs | 4 +- .../Report.cs | 4 +- .../SessionData.cs | 8 +- radiologists/src/samples/Workflow/README.md | 48 ++++++++ .../Configuration/AuthenticationOptions.cs | 2 +- .../Configuration/FoundryLocalSettings.cs | 4 +- .../Configuration/OpenAiSettings.cs | 2 +- .../Controllers/QualityCheckController.cs | 8 +- .../Extensions/ServiceCollectionExtensions.cs | 6 +- .../Extensions/WebApplicationExtensions.cs | 2 +- .../Program.cs | 14 +-- .../Properties/launchSettings.json | 0 .../README.md | 20 +-- ...SampleExtension.Radiologists.Web.Ai.csproj | 6 +- .../SampleExtension.Radiologists.Web.Ai.http | 8 +- .../Services/AzureOpenAIService.cs | 4 +- .../Services/FoundryLocalService.cs | 4 +- .../Services/IAzureOpenAIService.cs | 2 +- .../Services/IFoundryLocalService.cs | 2 +- .../Services/IQualityCheckService.cs | 4 +- .../Services/QualityCheckService.cs | 6 +- .../appsettings.Development.json | 2 +- .../appsettings.json | 2 +- .../nuget.config | 0 .../Configuration/AuthenticationOptions.cs | 2 +- .../Controllers/QualityCheckController.cs | 8 +- .../Extensions/ServiceCollectionExtensions.cs | 4 +- .../Extensions/WebApplicationExtensions.cs | 2 +- .../MockData/qualitycheck-response.json | 0 .../Program.cs | 12 +- .../Properties/launchSettings.json | 0 .../README.md | 22 ++-- ...tension.Radiologists.Web.Quickstart.csproj | 6 +- ...Extension.Radiologists.Web.Quickstart.http | 8 +- .../Services/IQualityCheckService.cs | 4 +- .../Services/QualityCheckService.cs | 4 +- .../appsettings.Development.json | 2 +- .../appsettings.json | 0 .../nuget.config | 0 .../SampleExtension.Radiologists.Web.slnx | 5 + .../.env.example | 2 +- .../.gitignore | 0 .../MockData/qualitycheck_response.json | 0 .../README.md | 66 +++++----- .../app/__init__.py | 0 .../app/auth.py | 0 .../app/config.py | 14 ++- .../app/main.py | 8 +- .../app/models.py | 8 +- .../app/service.py | 2 +- .../app/tests/__init__.py | 0 .../app/tests/conftest.py | 2 +- .../app/tests/test_auth.py | 0 .../app/tests/test_process.py | 0 .../extension.yaml | 2 +- .../requirements.txt | 0 ...ension_radiologists_python_quickstart.http | 0 .../samples/requests/FullRequest-Example.json | 0 .../PatientInformationRequest-Example.json | 0 .../QualityCheckResultResponse-Example.json | 0 .../requests/ReportRequest-Example.json | 0 .../requests/sample-requests-responses.md | 35 +++--- radiology/README.md | 42 ------- radiology/src/samples/Workflow/README.md | 49 -------- .../SampleExtension.Radiology.Web.slnx | 5 - tools/dragon-copilot-cli/scripts/bundle.cjs | 2 +- .../src/__tests__/cli-integration.test.ts | 14 +-- .../__tests__/command-registration.test.ts | 10 +- .../src/__tests__/manifest-validation.test.ts | 54 ++++---- .../dragon-copilot-cli/src/commands/index.ts | 4 +- .../commands/generate.ts | 12 +- .../commands/init.ts | 32 ++--- .../commands/package.ts | 12 +- .../commands/validate.ts | 6 +- .../{radiology => radiologists}/index.ts | 24 ++-- .../shared/prompts.ts | 22 ++-- .../shared/schema-validator.ts | 6 +- .../templates/index.ts | 2 +- .../{radiology => radiologists}/types.ts | 4 +- ...diologists-extension-manifest-schema.json} | 16 +-- .../src/shared/schema-validator.ts | 4 +- 98 files changed, 551 insertions(+), 547 deletions(-) create mode 100644 .github/instructions/radiologists.instructions.md delete mode 100644 .github/instructions/radiology.instructions.md rename .github/prompts/{radiology-scaffold-language-sample.prompt.md => radiologists-scaffold-language-sample.prompt.md} (70%) rename {radiology => radiologists}/Directory.Packages.props (100%) create mode 100644 radiologists/README.md rename radiology/radiology-extensibility-api.yaml => radiologists/radiologists-extensibility-api.yaml (95%) rename {radiology/src/models/Dragon.Copilot.Radiology.Models => radiologists/src/models/Dragon.Copilot.Radiologists.Models}/BiologicalSex.cs (92%) rename radiology/src/models/Dragon.Copilot.Radiology.Models/Dragon.Copilot.Radiology.Models.csproj => radiologists/src/models/Dragon.Copilot.Radiologists.Models/Dragon.Copilot.Radiologists.Models.csproj (100%) rename {radiology/src/models/Dragon.Copilot.Radiology.Models => radiologists/src/models/Dragon.Copilot.Radiologists.Models}/PatientInformation.cs (91%) rename {radiology/src/models/Dragon.Copilot.Radiology.Models => radiologists/src/models/Dragon.Copilot.Radiologists.Models}/ProcessRequest.cs (96%) rename {radiology/src/models/Dragon.Copilot.Radiology.Models => radiologists/src/models/Dragon.Copilot.Radiologists.Models}/ProcessResponse.cs (95%) rename {radiology/src/models/Dragon.Copilot.Radiology.Models => radiologists/src/models/Dragon.Copilot.Radiologists.Models}/Provenance.cs (93%) rename {radiology/src/models/Dragon.Copilot.Radiology.Models => radiologists/src/models/Dragon.Copilot.Radiologists.Models}/QualityCheckResult.cs (95%) rename {radiology/src/models/Dragon.Copilot.Radiology.Models => radiologists/src/models/Dragon.Copilot.Radiologists.Models}/QualityCheckType.cs (90%) rename {radiology/src/models/Dragon.Copilot.Radiology.Models => radiologists/src/models/Dragon.Copilot.Radiologists.Models}/Recommendation.cs (97%) rename {radiology/src/models/Dragon.Copilot.Radiology.Models => radiologists/src/models/Dragon.Copilot.Radiologists.Models}/ReferenceResource.cs (90%) rename {radiology/src/models/Dragon.Copilot.Radiology.Models => radiologists/src/models/Dragon.Copilot.Radiologists.Models}/Report.cs (80%) rename {radiology/src/models/Dragon.Copilot.Radiology.Models => radiologists/src/models/Dragon.Copilot.Radiologists.Models}/SessionData.cs (79%) create mode 100644 radiologists/src/samples/Workflow/README.md rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Configuration/AuthenticationOptions.cs (94%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Configuration/FoundryLocalSettings.cs (94%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Configuration/OpenAiSettings.cs (91%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Controllers/QualityCheckController.cs (91%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Extensions/ServiceCollectionExtensions.cs (96%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Extensions/WebApplicationExtensions.cs (93%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Program.cs (86%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Properties/launchSettings.json (100%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/README.md (90%) rename radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.csproj => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.csproj (87%) rename radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.http => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.http (67%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Services/AzureOpenAIService.cs (93%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Services/FoundryLocalService.cs (98%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Services/IAzureOpenAIService.cs (90%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Services/IFoundryLocalService.cs (92%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Services/IQualityCheckService.cs (80%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/Services/QualityCheckService.cs (98%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/appsettings.Development.json (80%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/appsettings.json (96%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai}/nuget.config (100%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/Configuration/AuthenticationOptions.cs (94%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/Controllers/QualityCheckController.cs (91%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/Extensions/ServiceCollectionExtensions.cs (97%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/Extensions/WebApplicationExtensions.cs (93%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/MockData/qualitycheck-response.json (100%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/Program.cs (85%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/Properties/launchSettings.json (100%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/README.md (80%) rename radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/SampleExtension.Radiology.Web.Quickstart.csproj => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/SampleExtension.Radiologists.Web.Quickstart.csproj (85%) rename radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/SampleExtension.Radiology.Web.Quickstart.http => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/SampleExtension.Radiologists.Web.Quickstart.http (65%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/Services/IQualityCheckService.cs (78%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/Services/QualityCheckService.cs (97%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/appsettings.Development.json (80%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/appsettings.json (100%) rename {radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart => radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart}/nuget.config (100%) create mode 100644 radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.slnx rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/.env.example (92%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/.gitignore (100%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/MockData/qualitycheck_response.json (100%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/README.md (72%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/app/__init__.py (100%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/app/auth.py (100%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/app/config.py (87%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/app/main.py (91%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/app/models.py (94%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/app/service.py (98%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/app/tests/__init__.py (100%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/app/tests/conftest.py (95%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/app/tests/test_auth.py (100%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/app/tests/test_process.py (100%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/extension.yaml (95%) rename {radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart}/requirements.txt (100%) rename radiology/src/samples/Workflow/sample_extension_radiology_python_quickstart/sample_extension_radiology_python_quickstart.http => radiologists/src/samples/Workflow/sample_extension_radiologists_python_quickstart/sample_extension_radiologists_python_quickstart.http (100%) rename {radiology => radiologists}/src/samples/requests/FullRequest-Example.json (100%) rename {radiology => radiologists}/src/samples/requests/PatientInformationRequest-Example.json (100%) rename {radiology => radiologists}/src/samples/requests/QualityCheckResultResponse-Example.json (100%) rename {radiology => radiologists}/src/samples/requests/ReportRequest-Example.json (100%) rename {radiology => radiologists}/src/samples/requests/sample-requests-responses.md (68%) delete mode 100644 radiology/README.md delete mode 100644 radiology/src/samples/Workflow/README.md delete mode 100644 radiology/src/samples/Workflow/SampleExtension.Radiology.Web.slnx rename tools/dragon-copilot-cli/src/domains/{radiology => radiologists}/commands/generate.ts (93%) rename tools/dragon-copilot-cli/src/domains/{radiology => radiologists}/commands/init.ts (76%) rename tools/dragon-copilot-cli/src/domains/{radiology => radiologists}/commands/package.ts (91%) rename tools/dragon-copilot-cli/src/domains/{radiology => radiologists}/commands/validate.ts (92%) rename tools/dragon-copilot-cli/src/domains/{radiology => radiologists}/index.ts (86%) rename tools/dragon-copilot-cli/src/domains/{radiology => radiologists}/shared/prompts.ts (93%) rename tools/dragon-copilot-cli/src/domains/{radiology => radiologists}/shared/schema-validator.ts (96%) rename tools/dragon-copilot-cli/src/domains/{radiology => radiologists}/templates/index.ts (97%) rename tools/dragon-copilot-cli/src/domains/{radiology => radiologists}/types.ts (95%) rename tools/dragon-copilot-cli/src/schemas/{radiology/radiology-extension-manifest-schema.json => radiologists/radiologists-extension-manifest-schema.json} (91%) diff --git a/.github/instructions/csharp-sample.instructions.md b/.github/instructions/csharp-sample.instructions.md index 9b25073..b0e21ff 100644 --- a/.github/instructions/csharp-sample.instructions.md +++ b/.github/instructions/csharp-sample.instructions.md @@ -1,15 +1,15 @@ --- -applyTo: '**/*.cs,**/*.csproj' +applyTo: "**/*.cs,**/*.csproj" --- # C# Sample Conventions — Copilot Instructions -Shared C# conventions used across the C# samples in this repository. This repository contains samples for multiple Dragon Copilot products (Physician, Radiology, and potentially others in the future). Product-specific patterns live in the matching `.instructions.md` overlay (loaded automatically via `applyTo: /**`). +Shared C# conventions used across the C# samples in this repository. This repository contains samples for multiple Dragon Copilot products (Physicians, Radiologists, and potentially others in the future). Product-specific patterns live in the matching `.instructions.md` overlay (loaded automatically via `applyTo: /**`). ## Stack - ASP.NET Core (Web SDK, `Microsoft.NET.Sdk.Web`). -- Target framework varies by product and sample: Physician uses `net9.0`; Radiology Quickstart uses `net10.0`; Radiology AI uses `net10.0-windows10.0.26100` (Windows-only due to Foundry Local). Check the product overlay or `.csproj` for the exact TFM. +- Target framework varies by product and sample: Physicians use `net9.0`; Radiologists Quickstart uses `net10.0`; Radiologists AI uses `net10.0-windows10.0.26100` (Windows-only due to Foundry Local). Check the product overlay or `.csproj` for the exact TFM. - `Microsoft.Identity.Web` and `Microsoft.AspNetCore.Authentication.JwtBearer` for Entra ID JWT bearer authentication. - `System.Text.Json` for serialization, with `JsonStringEnumConverter` registered for enum-as-string serialization and `PropertyNameCaseInsensitive = true`. - `Swashbuckle.AspNetCore` for OpenAPI / Swagger documentation in Development. @@ -31,13 +31,13 @@ Shared C# conventions used across the C# samples in this repository. This reposi ## Pipeline pattern -- Health-check endpoints return a JSON body (e.g. `{"status":"Healthy"}`); samples either use `MapGet` returning `Results.Ok(...)` or a `HealthCheckOptions.ResponseWriter` that writes JSON. Route names vary by product (Physician uses `/health`; Radiology uses `/health/liveness` and `/health/readiness`). +- Health-check endpoints return a JSON body (e.g. `{"status":"Healthy"}`); samples either use `MapGet` returning `Results.Ok(...)` or a `HealthCheckOptions.ResponseWriter` that writes JSON. Route names vary by product (Physicians use `/health`; Radiologists use `/health/liveness` and `/health/readiness`). - Wire up the security pipeline via `app.UseFullSecurity()`. - `UseFullSecurity` wraps `UseAuthentication` and `UseAuthorization` inside `app.UseWhen(ctx => !isPublicRoute(ctx), ...)` so public routes bypass JWT validation. - Public routes: - - `/health/liveness` - - `/health/readiness` - - Swagger UI at the application root in Development + - `/health/liveness` + - `/health/readiness` + - Swagger UI at the application root in Development ## Coding conventions @@ -56,4 +56,4 @@ The following patterns appear in some samples but are **not** universal across t - Source-generated `[LoggerMessage]` partials. - A per-sample `extension.yaml` manifest checked into the C# sample folder. -When working inside a specific product folder, also follow that product's overlay (e.g. `radiology.instructions.md`). +When working inside a specific product folder, also follow that product's overlay (e.g. `radiologists.instructions.md`). diff --git a/.github/instructions/python-sample.instructions.md b/.github/instructions/python-sample.instructions.md index 47ed79a..21e2622 100644 --- a/.github/instructions/python-sample.instructions.md +++ b/.github/instructions/python-sample.instructions.md @@ -1,21 +1,21 @@ --- -applyTo: '**/*.py,**/requirements.txt,**/pyproject.toml' +applyTo: "**/*.py,**/requirements.txt,**/pyproject.toml" --- # Python Sample Conventions — Copilot Instructions -Shared Python conventions for sample extensions in this repository. This repository contains samples for multiple Dragon Copilot products (Physician, Radiology, and potentially others in the future). Product-specific patterns live in the matching `.instructions.md` overlay. +Shared Python conventions for sample extensions in this repository. This repository contains samples for multiple Dragon Copilot products (Physicians, Radiologists, and potentially others in the future). Product-specific patterns live in the matching `.instructions.md` overlay. ## Stack -| Component | Version | -| --- | --- | -| Python | 3.12 | -| FastAPI | 0.116.1 | -| uvicorn | 0.35.0 | -| pydantic | 2.11.7 | -| pydantic-settings | 2.10.1 | -| pytest | 9.0.3 | +| Component | Version | +| ----------------- | ------- | +| Python | 3.12 | +| FastAPI | 0.116.1 | +| uvicorn | 0.35.0 | +| pydantic | 2.11.7 | +| pydantic-settings | 2.10.1 | +| pytest | 9.0.3 | Pin these exact versions in `requirements.txt`. Use `requirements.txt`, not `pyproject.toml`, to match the existing precedent and keep installation simple for partners. @@ -31,7 +31,7 @@ Pin these exact versions in `requirements.txt`. Use `requirements.txt`, not `pyp ├── __init__.py ├── main.py # FastAPI app, /v1/process, /health endpoints ├── config.py # pydantic-settings - ├── models.py # Pydantic mirrors of the C# models (Physician: DragonStandardPayload; Radiology: ProcessRequest/ProcessResponse) + ├── models.py # Pydantic mirrors of the C# models (Physicians: DragonStandardPayload; Radiologists: ProcessRequest/ProcessResponse) ├── service.py # Business logic / mock data fallback └── tests/ ├── __init__.py @@ -41,7 +41,7 @@ Pin these exact versions in `requirements.txt`. Use `requirements.txt`, not `pyp ## Endpoint pattern - Use FastAPI's decorator-based routing: `@app.post("/v1/process")`. -- Define request and response models with Pydantic; do not return raw dicts. The exact DTO types vary by product — Physician uses `DragonStandardPayload`, Radiology uses `ProcessRequest`/`ProcessResponse`. Mirror the corresponding C# models project (`physician/src/models/` or `radiology/src/models/`). +- Define request and response models with Pydantic; do not return raw dicts. The exact DTO types vary by product — Physicians use `DragonStandardPayload`, Radiologists use `ProcessRequest`/`ProcessResponse`. Mirror the corresponding C# models project (`physician/src/models/` or `radiologists/src/models/`). - Health endpoints at `/health/liveness` and `/health/readiness` return JSON status payloads, matching the C# samples and the scaffold prompt. - Enable FastAPI's automatic Swagger / OpenAPI generation; expose it at `/docs`. @@ -54,7 +54,7 @@ Pin these exact versions in `requirements.txt`. Use `requirements.txt`, not `pyp ## Naming - snake_case for filenames inside the `app/` package. -- Mock data filenames use the language's idiomatic casing (e.g., `qualitycheck_response.json` for Radiology). The exact filename varies — check the corresponding C# sample's `MockData/` folder. +- Mock data filenames use the language's idiomatic casing (e.g., `qualitycheck_response.json` for Radiologists). The exact filename varies — check the corresponding C# sample's `MockData/` folder. - PascalCase for Pydantic model classes mirroring C# DTOs (`Report`, `PatientInformation`, `DragonStandardPayload`, etc.). - snake_case for field names exposed by Pydantic; use `alias` / `populate_by_name` if the JSON contract uses camelCase. @@ -73,7 +73,7 @@ python3.12 -m venv .venv && source .venv/bin/activate && python3.12 -m pip insta python3.12 -m uvicorn app.main:app --host 0.0.0.0 --port --reload ``` -Pick a port that matches the C# sample default for the same product (Physician: 5181, Radiology: 5080) so partners can swap implementations without changing client URLs. +Pick a port that matches the C# sample default for the same product (Physicians: 5181, Radiologists: 5080) so partners can swap implementations without changing client URLs. ## Testing diff --git a/.github/instructions/radiologists.instructions.md b/.github/instructions/radiologists.instructions.md new file mode 100644 index 0000000..0349e4c --- /dev/null +++ b/.github/instructions/radiologists.instructions.md @@ -0,0 +1,115 @@ +--- +applyTo: "radiologists/**" +--- + +# Radiologists Extension Samples — Copilot Instructions + +Radiologists extensions analyze radiology reports and return quality-check recommendations. + +## Authoritative contract + +- **OpenAPI spec:** `radiologists/radiologists-extensibility-api.yaml` is the canonical wire contract for `POST /v1/process`. It defines the envelope as `ProcessRequest` (request) and `ProcessResponse` (response), and contains the full schema definitions for all Radiologists domain types (`SessionData`, `PatientInformation`, `Report`, `QualityCheckResult`, `Recommendation`, `Provenance`, `ReferenceResource`). +- **Models project:** `radiologists/src/models/Dragon.Copilot.Radiologists.Models/` — C# classes that mirror the OpenAPI spec (`ProcessRequest`, `ProcessResponse`, `SessionData`, `PatientInformation`, `Report`, `QualityCheckResult`, …). The wire envelope lives **here**, not in each sample. +- **Wire shape (from the spec):** + - Request `ProcessRequest`: required `sessionData`; optional `extensibilityApiVersion` (string, e.g. `"1.1.1"`, informational metadata from Dragon Copilot), `patientInformation`, and `report`. Additional named inputs flow through `additionalProperties`. The Radiologists C# model declares `patientInformation` and `report` as explicit properties for convenience. + - Response `ProcessResponse`: optional `success`, `message`, and `payload` — a **map** of output name → `QualityCheckResult` (the output name comes from the extension's manifest, e.g. `qualityCheckResult`). +- **Field casing on the wire is mixed**, matching the YAML: top-level uses camelCase (`extensibilityApiVersion`, `sessionData`, `patientInformation`, `report`); `SessionData` fields are snake_case (`correlation_id`, `session_start`, `environment_id`); `PatientInformation` and `Report` fields are camelCase (`dateOfBirth`, `biologicalSex`, `reportText`). + +## Sample variants + +Two C# sample variants live under `radiologists/src/samples/Workflow/`: + +| Variant | Folder | Purpose | Target | Platform | +| ---------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ---------------------------- | +| Quickstart | `SampleExtension.Radiologists.Web.Quickstart/` | Returns a canned response from `MockData/qualitycheck-response.json`. The fastest way to get a working extension running locally. | `net10.0` | Cross-platform | +| Ai | `SampleExtension.Radiologists.Web.Ai/` | Calls Azure OpenAI when its config is populated; otherwise calls Foundry Local on-device inference when enabled. Throws if neither is configured. | `net10.0-windows10.0.26100` | Windows-only (Foundry Local) | + +## Stack facts + +- C# target framework: `net10.0` (Quickstart), `net10.0-windows10.0.26100` (AI sample, due to Foundry Local dependency). +- Default dev ports: **5080** (HTTP), **7080** (HTTPS). +- `Authentication.Enabled` is `false` by default in `appsettings.json` so partners can clone and run without setting up Entra ID first. +- Health probes at `/health/liveness` and `/health/readiness`, returning a JSON body (e.g. `{"status":"Healthy"}`) via a health-check response writer. +- Swagger UI is served at the application root in Development. + +## Domain types + +Defined in `Dragon.Copilot.Radiologists.Models`: + +- `Report` — the radiology report text and metadata +- `PatientInformation` — patient demographics relevant to the report +- `QualityCheckResult` — the structured result returned to Dragon Copilot +- `Recommendation` — an individual quality-check finding +- `Provenance` — the span of report text a recommendation was derived from (`text`, `startPosition`, `endPosition`) +- `ReferenceResource` — supporting references attached to a recommendation +- `BiologicalSex`, `QualityCheckType` — enums (`Billing`, `Clinical`) + +## Quality-check service + +Both sample variants use `IQualityCheckService.ProcessAsync` (async with `CancellationToken`) as the single integration point. Replace its implementation to wire in your own logic. + +- **Quickstart variant:** Returns the canned response in `MockData/qualitycheck-response.json`. Partners can edit the JSON directly to tweak the stubbed output without rebuilding (the file is copied to the build output with `PreserveNewest`). +- **Ai variant:** Selects a provider per request from configuration: + 1. **Azure OpenAI** when the `OpenAI` section in `appsettings.json` has `Endpoint`, `ApiKey`, and `DeploymentName` populated. + 2. Otherwise **Foundry Local** when `FoundryLocal:Enabled` is `true`. + + **Graceful fallback:** If the model returns malformed JSON or omits the expected `qualityCheckResult` property, the service logs a warning and returns a well-formed `ProcessResponse` with an empty recommendations list instead of throwing. Partners adapting this sample can replace this fallback with their own error-handling strategy. + + The full AI system prompt lives in code at `SampleExtension.Radiologists.Web.Ai/Services/QualityCheckService.cs` as the private `SystemPrompt` const, so it stays in sync with the running code. + +## Endpoint shape + +Both samples use an async controller action with `CancellationToken`: + +```csharp +[ApiController] +[Route("v1")] +[Produces("application/json")] +[Authorize(Policy = "RequiredClaims")] +public sealed class QualityCheckController : ControllerBase +{ + [HttpPost("process")] + public async Task> PostAsync( + [FromBody] ProcessRequest payload, + CancellationToken cancellationToken) { ... } +} +``` + +## Manifest format (Radiologists) + +Radiologists extension manifests differ from Physicians manifests. Key required fields: + +```yaml +name: sampleQualityCheckExtension # camelCase, starts lowercase +description: Extension to provide radiology report quality checking +version: 0.0.1 # Partner's own version (x.y.z) +radiologistsExtensibilityApiVersion: 1.0.0 # API version from radiologists-extensibility-api.yaml +auth: + tenantId: 00000000-0000-0000-0000-000000000000 +tools: + - name: sampleQualityCheckTool # camelCase, starts lowercase + toolType: contractBased # Required for Radiologists + capability: qualityCheck # Required for Radiologists + description: Tool to check quality of a radiology report + endpoint: https://publisher.example.com/quality-check + inputs: + - name: report + description: Radiology report from Dragon Copilot + content-type: application/vnd.ms-dragon.rad.report+json + schemaVersion: "1.0" # Required: version of Report schema accepted + - name: patientInformation + description: Patient demographic information from Dragon Copilot + content-type: application/vnd.ms-dragon.rad.patient-information+json + schemaVersion: "1.0" # Required: version of PatientInformation schema accepted + outputs: + - name: qualityCheckResult + description: Quality check findings and score + content-type: application/vnd.ms-dragon.rad.quality-check-result+json + schemaVersion: "1.0" # Required: version of QualityCheckResult schema produced +``` + +See `tools/dragon-copilot-cli/src/schemas/radiologists/radiologists-extension-manifest-schema.json` for the full JSON Schema. + +## Scaffolding a sample in another language + +When a partner wants a Radiologists sample in a language other than C# (for example Python, Go, Java, or Node.js), invoke the reusable Copilot prompt at `.github/prompts/radiologists-scaffold-language-sample.prompt.md`. Its usage instructions live inside the prompt file itself. diff --git a/.github/instructions/radiology.instructions.md b/.github/instructions/radiology.instructions.md deleted file mode 100644 index a10bcec..0000000 --- a/.github/instructions/radiology.instructions.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -applyTo: "radiology/**" ---- - -# Radiology Extension Samples — Copilot Instructions - -Radiology extensions analyze radiology reports and return quality-check recommendations. - -## Authoritative contract - -- **OpenAPI spec:** `radiology/radiology-extensibility-api.yaml` is the canonical wire contract for `POST /v1/process`. It defines the envelope as `ProcessRequest` (request) and `ProcessResponse` (response), and contains the full schema definitions for all Radiology domain types (`SessionData`, `PatientInformation`, `Report`, `QualityCheckResult`, `Recommendation`, `Provenance`, `ReferenceResource`). -- **Models project:** `radiology/src/models/Dragon.Copilot.Radiology.Models/` — C# classes that mirror the OpenAPI spec (`ProcessRequest`, `ProcessResponse`, `SessionData`, `PatientInformation`, `Report`, `QualityCheckResult`, …). The wire envelope lives **here**, not in each sample. -- **Wire shape (from the spec):** - - Request `ProcessRequest`: required `sessionData`; optional `extensibilityApiVersion` (string, e.g. `"1.1.1"`, informational metadata from Dragon Copilot), `patientInformation`, and `report`. Additional named inputs flow through `additionalProperties`. The Radiology C# model declares `patientInformation` and `report` as explicit properties for convenience. - - Response `ProcessResponse`: optional `success`, `message`, and `payload` — a **map** of output name → `QualityCheckResult` (the output name comes from the extension's manifest, e.g. `qualityCheckResult`). -- **Field casing on the wire is mixed**, matching the YAML: top-level uses camelCase (`extensibilityApiVersion`, `sessionData`, `patientInformation`, `report`); `SessionData` fields are snake_case (`correlation_id`, `session_start`, `environment_id`); `PatientInformation` and `Report` fields are camelCase (`dateOfBirth`, `biologicalSex`, `reportText`). - -## Sample variants - -Two C# sample variants live under `radiology/src/samples/Workflow/`: - -| Variant | Folder | Purpose | Target | Platform | -| ---------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------- | -| Quickstart | `SampleExtension.Radiology.Web.Quickstart/` | Returns a canned response from `MockData/qualitycheck-response.json`. The fastest way to get a working extension running locally. | `net10.0` | Cross-platform | -| Ai | `SampleExtension.Radiology.Web.Ai/` | Calls Azure OpenAI when its config is populated; otherwise calls Foundry Local on-device inference when enabled. Throws if neither is configured. | `net10.0-windows10.0.26100` | Windows-only (Foundry Local) | - -## Stack facts - -- C# target framework: `net10.0` (Quickstart), `net10.0-windows10.0.26100` (AI sample, due to Foundry Local dependency). -- Default dev ports: **5080** (HTTP), **7080** (HTTPS). -- `Authentication.Enabled` is `false` by default in `appsettings.json` so partners can clone and run without setting up Entra ID first. -- Health probes at `/health/liveness` and `/health/readiness`, returning a JSON body (e.g. `{"status":"Healthy"}`) via a health-check response writer. -- Swagger UI is served at the application root in Development. - -## Domain types - -Defined in `Dragon.Copilot.Radiology.Models`: - -- `Report` — the radiology report text and metadata -- `PatientInformation` — patient demographics relevant to the report -- `QualityCheckResult` — the structured result returned to Dragon Copilot -- `Recommendation` — an individual quality-check finding -- `Provenance` — the span of report text a recommendation was derived from (`text`, `startPosition`, `endPosition`) -- `ReferenceResource` — supporting references attached to a recommendation -- `BiologicalSex`, `QualityCheckType` — enums (`Billing`, `Clinical`) - -## Quality-check service - -Both sample variants use `IQualityCheckService.ProcessAsync` (async with `CancellationToken`) as the single integration point. Replace its implementation to wire in your own logic. - -- **Quickstart variant:** Returns the canned response in `MockData/qualitycheck-response.json`. Partners can edit the JSON directly to tweak the stubbed output without rebuilding (the file is copied to the build output with `PreserveNewest`). -- **Ai variant:** Selects a provider per request from configuration: - 1. **Azure OpenAI** when the `OpenAI` section in `appsettings.json` has `Endpoint`, `ApiKey`, and `DeploymentName` populated. - 2. Otherwise **Foundry Local** when `FoundryLocal:Enabled` is `true`. - - **Graceful fallback:** If the model returns malformed JSON or omits the expected `qualityCheckResult` property, the service logs a warning and returns a well-formed `ProcessResponse` with an empty recommendations list instead of throwing. Partners adapting this sample can replace this fallback with their own error-handling strategy. - - The full AI system prompt lives in code at `SampleExtension.Radiology.Web.Ai/Services/QualityCheckService.cs` as the private `SystemPrompt` const, so it stays in sync with the running code. - -## Endpoint shape - -Both samples use an async controller action with `CancellationToken`: - -```csharp -[ApiController] -[Route("v1")] -[Produces("application/json")] -[Authorize(Policy = "RequiredClaims")] -public sealed class QualityCheckController : ControllerBase -{ - [HttpPost("process")] - public async Task> PostAsync( - [FromBody] ProcessRequest payload, - CancellationToken cancellationToken) { ... } -} -``` - -## Manifest format (Radiology) - -Radiology extension manifests differ from Physician manifests. Key required fields: - -```yaml -name: sampleQualityCheckExtension # camelCase, starts lowercase -description: Extension to provide radiology report quality checking -version: 0.0.1 # Partner's own version (x.y.z) -radiologyExtensibilityApiVersion: 1.0.0 # API version from radiology-extensibility-api.yaml -auth: - tenantId: 00000000-0000-0000-0000-000000000000 -tools: - - name: sampleQualityCheckTool # camelCase, starts lowercase - toolType: contractBased # Required for Radiology - capability: qualityCheck # Required for Radiology - description: Tool to check quality of a radiology report - endpoint: https://publisher.example.com/quality-check - inputs: - - name: report - description: Radiology report from Dragon Copilot - content-type: application/vnd.ms-dragon.rad.report+json - schemaVersion: "1.0" # Required: version of Report schema accepted - - name: patientInformation - description: Patient demographic information from Dragon Copilot - content-type: application/vnd.ms-dragon.rad.patient-information+json - schemaVersion: "1.0" # Required: version of PatientInformation schema accepted - outputs: - - name: qualityCheckResult - description: Quality check findings and score - content-type: application/vnd.ms-dragon.rad.quality-check-result+json - schemaVersion: "1.0" # Required: version of QualityCheckResult schema produced -``` - -See `tools/dragon-copilot-cli/src/schemas/radiology/radiology-extension-manifest-schema.json` for the full JSON Schema. - -## Scaffolding a sample in another language - -When a partner wants a Radiology sample in a language other than C# (for example Python, Go, Java, or Node.js), invoke the reusable Copilot prompt at `.github/prompts/radiology-scaffold-language-sample.prompt.md`. Its usage instructions live inside the prompt file itself. diff --git a/.github/prompts/radiology-scaffold-language-sample.prompt.md b/.github/prompts/radiologists-scaffold-language-sample.prompt.md similarity index 70% rename from .github/prompts/radiology-scaffold-language-sample.prompt.md rename to .github/prompts/radiologists-scaffold-language-sample.prompt.md index 763a52c..9ad5982 100644 --- a/.github/prompts/radiology-scaffold-language-sample.prompt.md +++ b/.github/prompts/radiologists-scaffold-language-sample.prompt.md @@ -1,11 +1,11 @@ --- -description: "Scaffold a Radiology extension sample in the language of your choice, mirroring the C# Quickstart." +description: "Scaffold a Radiologists extension sample in the language of your choice, mirroring the C# Quickstart." mode: agent --- -# Scaffold a Radiology sample in another language +# Scaffold a Radiologists sample in another language -Generate a new Radiology extension sample, in the language the user provides, that implements the **same wire contract** as the C# Quickstart and can be checked into this repository once reviewed. Treat the rest of this file as your working instructions and reference material. +Generate a new Radiologists extension sample, in the language the user provides, that implements the **same wire contract** as the C# Quickstart and can be checked into this repository once reviewed. Treat the rest of this file as your working instructions and reference material. ## Start here — establish the target language @@ -19,7 +19,7 @@ Keep that first reply to just the question. Begin reading the source material an ## How a partner uses this prompt 1. Open the repo in an editor with GitHub Copilot Chat enabled (for example Visual Studio or VS Code). -2. In Copilot Chat, type `/` and select `radiology-scaffold-language-sample`. +2. In Copilot Chat, type `/` and select `radiologists-scaffold-language-sample`. 3. Provide the target language when asked (for example `python`, `go`, `java`, `nodejs`, `typescript`, `rust`). 4. Review, run, and adjust before committing. @@ -29,21 +29,21 @@ Keep that first reply to just the question. Begin reading the source material an ## Source material to read first -1. **`radiology/radiology-extensibility-api.yaml` is the canonical wire contract** for Radiology. It defines `POST /v1/process` with `ProcessRequest` as the request envelope and `ProcessResponse` as the response envelope, and contains the full schema definitions for all Radiology domain types. Read it first. +1. **`radiologists/radiologists-extensibility-api.yaml` is the canonical wire contract** for Radiologists. It defines `POST /v1/process` with `ProcessRequest` as the request envelope and `ProcessResponse` as the response envelope, and contains the full schema definitions for all Radiologists domain types. Read it first. 2. **The C# Quickstart shows the canonical implementation** of that contract. Read it end-to-end: - - `radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/Controllers/QualityCheckController.cs` + - `radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Controllers/QualityCheckController.cs` - `.../Services/IQualityCheckService.cs`, `.../Services/QualityCheckService.cs` - `.../MockData/qualitycheck-response.json` — canned response to reuse verbatim - `.../appsettings.json` — `Authentication` configuration shape - `.../Program.cs` — middleware order (CORS, Swagger-at-root in Development, health endpoints, auth) - `.../README.md` — README structure to mirror - - `.../SampleExtension.Radiology.Web.Quickstart.http` — canonical sample payload (note: field casing mixes snake_case and camelCase per spec; reproduce it exactly) -3. **The shared models project** `radiology/src/models/Dragon.Copilot.Radiology.Models/` is where the wire envelope lives in C# (`ProcessRequest`, `ProcessResponse`, `SessionData`, `PatientInformation`, `Report`, `QualityCheckResult`, `Recommendation`, `Provenance`, `ReferenceResource`, `BiologicalSex`, `QualityCheckType`). The samples themselves do **not** have a `Models/` folder; mirror the types from this shared project. + - `.../SampleExtension.Radiologists.Web.Quickstart.http` — canonical sample payload (note: field casing mixes snake_case and camelCase per spec; reproduce it exactly) +3. **The shared models project** `radiologists/src/models/Dragon.Copilot.Radiologists.Models/` is where the wire envelope lives in C# (`ProcessRequest`, `ProcessResponse`, `SessionData`, `PatientInformation`, `Report`, `QualityCheckResult`, `Recommendation`, `Provenance`, `ReferenceResource`, `BiologicalSex`, `QualityCheckType`). The samples themselves do **not** have a `Models/` folder; mirror the types from this shared project. 4. Per-language overlay (load only if it exists for the chosen language): `.github/instructions/-sample.instructions.md`. If present, follow it strictly. ## Wire contract (lock this down — non-negotiable) -This section reflects `radiology/radiology-extensibility-api.yaml` and the canonical `.http` payload exactly. **Do not rename fields, do not change casing.** +This section reflects `radiologists/radiologists-extensibility-api.yaml` and the canonical `.http` payload exactly. **Do not rename fields, do not change casing.** **`POST /v1/process`** request body (`ProcessRequest`): @@ -85,11 +85,11 @@ Field casing is **mixed** and must be reproduced exactly: - `PatientInformation` and `Report` fields are **camelCase**: `dateOfBirth`, `biologicalSex`, `reportText`. - `payload` on the response is a **map**, not a fixed object. The key (`qualityCheckResult` in the canned mock) is declared by the extension's `extension.yaml` `outputs[].name`. -Only `sessionData` is required on the request per the OpenAPI spec. `Recommendation`, `Provenance`, `ReferenceResource`, `BiologicalSex`, `QualityCheckType` mirror the shared `Dragon.Copilot.Radiology.Models` types exactly. +Only `sessionData` is required on the request per the OpenAPI spec. `Recommendation`, `Provenance`, `ReferenceResource`, `BiologicalSex`, `QualityCheckType` mirror the shared `Dragon.Copilot.Radiologists.Models` types exactly. ## Folder and naming conventions -Folder lives at `radiology/src/samples/Workflow//`. The folder name **reads as** "sample extension radiology `` quickstart" using the **language's idiomatic package-naming convention** (snake_case for Python/Rust, kebab-case for Node/TypeScript/Go/Java, dotted PascalCase for C#). +Folder lives at `radiologists/src/samples/Workflow//`. The folder name **reads as** "sample extension radiologists `` quickstart" using the **language's idiomatic package-naming convention** (snake_case for Python/Rust, kebab-case for Node/TypeScript/Go/Java, dotted PascalCase for C#). Use the same lowercase language token (`python`, `nodejs`, `typescript`, `go`, `java`, `rust`) consistently in code (namespaces, packages, module names) and README headings. Do not introduce a separate PascalCase variant. @@ -99,12 +99,12 @@ Use the same lowercase language token (`python`, `nodejs`, `typescript`, `go`, ` 1. **Endpoint:** `POST /v1/process` accepting the request body above, returning the response body above. Bind to **HTTP port 5080** by default; document `--port` override in the README (5080 collides with the C# sample if both run on the same host). 2. **Health probes:** `GET /health/liveness` and `GET /health/readiness`, each returning a JSON body `{"status": "Healthy"}` on success (matching the C# samples, which write JSON from their health-check response writer). -3. **Domain types:** Mirror `Dragon.Copilot.Radiology.Models` in the target language. Internal code uses the language's idiomatic casing (snake_case in Python/Rust, exported PascalCase in Go, camelCase in Java/Kotlin/Node/TS). Wire JSON is **camelCase**; add a language-idiomatic alias/tag/annotation layer to map between the two (this is the same pattern C# uses with `[JsonPropertyName]`). +3. **Domain types:** Mirror `Dragon.Copilot.Radiologists.Models` in the target language. Internal code uses the language's idiomatic casing (snake_case in Python/Rust, exported PascalCase in Go, camelCase in Java/Kotlin/Node/TS). Wire JSON is **camelCase**; add a language-idiomatic alias/tag/annotation layer to map between the two (this is the same pattern C# uses with `[JsonPropertyName]`). ### Implementation 4. **Service layer:** A single `QualityCheckService` (or language equivalent) with one method that takes `ProcessRequest` and returns `ProcessResponse`. Use the language's idiomatic async pattern if the framework is async-first (the C# samples expose `ProcessAsync` with a cancellation token). The only logic is loading the canned mock data. Partners replace this method with their real implementation. -5. **Mock data:** Copy `radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/MockData/qualitycheck-response.json` **verbatim** to a top-level `MockData/` folder inside the new sample (match the C# layout — do not nest it under a source/package directory). Filename uses the language's idiomatic casing for resource files: +5. **Mock data:** Copy `radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/MockData/qualitycheck-response.json` **verbatim** to a top-level `MockData/` folder inside the new sample (match the C# layout — do not nest it under a source/package directory). Filename uses the language's idiomatic casing for resource files: - Python / Rust → `qualitycheck_response.json` - C# / Node / TypeScript → `qualitycheck-response.json` - Java → `qualityCheckResponse.json` @@ -131,25 +131,25 @@ Use the same lowercase language token (`python`, `nodejs`, `typescript`, `go`, ` ### Files & docs -12. **`extension.yaml`:** Include a Radiology manifest at the sample root. **Radiology manifests differ from Physician manifests** — use this structure: +12. **`extension.yaml`:** Include a Radiologists manifest at the sample root. **Radiologists manifests differ from Physicians manifests** — use this structure: name: sampleQualityCheckExtension # camelCase, starts lowercase description: Sample radiology quality check extension version: 0.0.1 - radiologyExtensibilityApiVersion: 1.0.0 # Required for Radiology + radiologistsExtensibilityApiVersion: 1.0.0 # Required for Radiologists auth: tenantId: 00000000-0000-0000-0000-000000000000 tools: - name: sampleQualityCheckTool - toolType: contractBased # Required for Radiology - capability: qualityCheck # Required for Radiology + toolType: contractBased # Required for Radiologists + capability: qualityCheck # Required for Radiologists description: Tool to check quality of a radiology report endpoint: http://localhost:5080/v1/process inputs: - name: report description: Radiology report from Dragon Copilot content-type: application/vnd.ms-dragon.rad.report+json - schemaVersion: "1.0" # Required for Radiology + schemaVersion: "1.0" # Required for Radiologists - name: patientInformation description: Patient demographic information content-type: application/vnd.ms-dragon.rad.patient-information+json @@ -160,9 +160,9 @@ Use the same lowercase language token (`python`, `nodejs`, `typescript`, `go`, ` content-type: application/vnd.ms-dragon.rad.quality-check-result+json schemaVersion: "1.0" - See `tools/dragon-copilot-cli/src/schemas/radiology/radiology-extension-manifest-schema.json` for the full JSON Schema. + See `tools/dragon-copilot-cli/src/schemas/radiologists/radiologists-extension-manifest-schema.json` for the full JSON Schema. -13. **README:** Mirror the structure of `radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Quickstart/README.md`. Required sections: What's included, API endpoints (table), Run locally (separate Linux/macOS and Windows PowerShell blocks), Testing the API (with both `curl` and `Invoke-RestMethod` examples for `/v1/process` and both health probes), Security (with explicit Entra ID enable steps), Quality-check provider, Request/response contract. Add a Running the tests section as well (the C# samples ship no tests, but other-language samples do — see item 14). +13. **README:** Mirror the structure of `radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/README.md`. Required sections: What's included, API endpoints (table), Run locally (separate Linux/macOS and Windows PowerShell blocks), Testing the API (with both `curl` and `Invoke-RestMethod` examples for `/v1/process` and both health probes), Security (with explicit Entra ID enable steps), Quality-check provider, Request/response contract. Add a Running the tests section as well (the C# samples ship no tests, but other-language samples do — see item 14). ### Validation @@ -172,7 +172,7 @@ Use the same lowercase language token (`python`, `nodejs`, `typescript`, `go`, ` - `/v1/process` happy path against the canonical sample payload returns the canned response (assert 3 recommendations, the `Clinical` + `Billing` types present, `severityScorePercent` 85 on the "paddock steatosis" recommendation) - `/v1/process` returns the framework's default validation error (typically 4xx) when required fields are missing - Auth toggle: enabled ⇒ unauthenticated request returns 401; enabled + valid bearer token ⇒ 200 (mock the JWKS / signing key in tests so this can run hermetically) -15. **Sample payload:** Use `radiology/src/samples/requests/FullRequest-Example.json` as the canonical complete request, or compose from fragments (`PatientInformationRequest-Example.json`, `ReportRequest-Example.json`). The body in `SampleExtension.Radiology.Web.Quickstart.http` is the canonical reference. All patient data must remain fictional. +15. **Sample payload:** Use `radiologists/src/samples/requests/FullRequest-Example.json` as the canonical complete request, or compose from fragments (`PatientInformationRequest-Example.json`, `ReportRequest-Example.json`). The body in `SampleExtension.Radiologists.Web.Quickstart.http` is the canonical reference. All patient data must remain fictional. ## Hard rules diff --git a/radiology/Directory.Packages.props b/radiologists/Directory.Packages.props similarity index 100% rename from radiology/Directory.Packages.props rename to radiologists/Directory.Packages.props diff --git a/radiologists/README.md b/radiologists/README.md new file mode 100644 index 0000000..0a0d252 --- /dev/null +++ b/radiologists/README.md @@ -0,0 +1,42 @@ +# Dragon Copilot (radiologists) Extension Samples + +Welcome! This section contains sample code and documentation for building **Dragon Copilot (radiologists)** extensions. You can read, play with, or adapt from these samples to create your own extensions. + +> ⚠️ **Work in progress**: Radiologists Workflows are still in-development and will change. + +## 📚 Contents + +- [Dragon Copilot Extension Samples](#dragon-copilot-extension-samples) + - [📝 Overview](#-overview) + - [🚀 Getting Started](#-getting-started) + - [️ Tools](#️-tools) + +## 📝 Overview + +Key resources: + +- [Shared Platform Documentation](../doc/) — Authentication guides and resources common across all products +- Sample [`Radiologists Workflow`](src/) with best practices +- CLI tools to initialize, generate manifests, validate, and package Radiologists Workflows + +### Radiologists Extensions Overview + +| Type | Description | Use Case | +| ------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| **Radiologists Workflow** | Custom AI-powered extensions with automation scripts, event triggers, and dependencies | Extend Dragon Copilot with custom radiology data processing | + +### Versioning + +Three independent version axes appear in these artifacts. They are **declarations recorded at manifest upload time** and are not part of each `POST /v1/process` payload (apart from the optional `extensibilityApiVersion` field on the request envelope, which is informational): + +- **API version** — `info.version` in [`radiologists-extensibility-api.yaml`](radiologists-extensibility-api.yaml) (semantic `x.y.z`). The version of the extensibility API contract as a whole. A Partner records the version they built against in their manifest's `radiologistsExtensibilityApiVersion` field. The same API version may also appear on each request as the optional `extensibilityApiVersion` field on the `ProcessRequest` envelope (informational). +- **Extension version** — the manifest's top-level `version` field (`x.y.z`). The Partner's own product version for their extension, independent of the API version. +- **Payload schema version** — each payload schema (`Report`, `PatientInformation`, `QualityCheckResult`) declares its own version via the `x-ms-schema-version` annotation in [`radiologists-extensibility-api.yaml`](radiologists-extensibility-api.yaml) (`major.minor`). The Partner declares which version of each payload they accept (inputs) or produce (outputs) via the required `schemaVersion` field on every input and output in their manifest. This gives per-payload traceability — e.g. "this extension accepts `Report` v1.0" — without putting a version on the wire payloads themselves. + +## 🚀 Getting Started + +For repo setup, cloning instructions, and contributing guidelines, see the [root README](../README.md). + +## 🛠️ Tools + +See the [Dragon Copilot CLI](../tools/dragon-copilot-cli/README.md) for tools to initialize, generate manifests, validate, and package radiologists extensions. diff --git a/radiology/radiology-extensibility-api.yaml b/radiologists/radiologists-extensibility-api.yaml similarity index 95% rename from radiology/radiology-extensibility-api.yaml rename to radiologists/radiologists-extensibility-api.yaml index f34c4dc..5365d75 100644 --- a/radiology/radiology-extensibility-api.yaml +++ b/radiologists/radiologists-extensibility-api.yaml @@ -1,18 +1,16 @@ -# OpenAPI spec defining the API contract for extensions for Dragon Copilot for Radiology. - openapi: 3.0.0 info: - title: Radiology Extensibility API + title: Extensibility API for Dragon Copilot (radiologists) version: 1.0.0 - description: API definition for enabling Dragon Copilot for Radiology extension integrations + description: API definition for enabling partner extension integrations paths: /v1/process: post: - summary: Process Radiology Standard payload + summary: Process a standard payload for Dragon Copilot (radiologists) description: >- - Endpoint to trigger processing within a Radiology extension. - operationId: processDragonRadiology + Endpoint to trigger processing within a partner extension. + operationId: processDragonRadiologists parameters: - name: x-ms-request-id in: header diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/BiologicalSex.cs b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/BiologicalSex.cs similarity index 92% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/BiologicalSex.cs rename to radiologists/src/models/Dragon.Copilot.Radiologists.Models/BiologicalSex.cs index a88430c..d364d1b 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/BiologicalSex.cs +++ b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/BiologicalSex.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Dragon.Copilot.Radiology.Models +namespace Dragon.Copilot.Radiologists.Models { /// /// Biological sex of the patient. diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/Dragon.Copilot.Radiology.Models.csproj b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/Dragon.Copilot.Radiologists.Models.csproj similarity index 100% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/Dragon.Copilot.Radiology.Models.csproj rename to radiologists/src/models/Dragon.Copilot.Radiologists.Models/Dragon.Copilot.Radiologists.Models.csproj diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/PatientInformation.cs b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/PatientInformation.cs similarity index 91% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/PatientInformation.cs rename to radiologists/src/models/Dragon.Copilot.Radiologists.Models/PatientInformation.cs index 5219216..012da49 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/PatientInformation.cs +++ b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/PatientInformation.cs @@ -1,12 +1,12 @@ using System.Text.Json.Serialization; -namespace Dragon.Copilot.Radiology.Models +namespace Dragon.Copilot.Radiologists.Models { /// /// Patient demographic information. /// /// - /// Corresponds to the PatientInformation schema defined in radiology-extensibility-api.yaml. + /// Corresponds to the PatientInformation schema defined in radiologists-extensibility-api.yaml. /// /// /// diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessRequest.cs b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/ProcessRequest.cs similarity index 96% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessRequest.cs rename to radiologists/src/models/Dragon.Copilot.Radiologists.Models/ProcessRequest.cs index 867c9ca..700e695 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessRequest.cs +++ b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/ProcessRequest.cs @@ -2,13 +2,13 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Dragon.Copilot.Radiology.Models; +namespace Dragon.Copilot.Radiologists.Models; /// /// Request envelope for the /v1/process endpoint. /// /// -/// Corresponds to the ProcessRequest schema defined in radiology-extensibility-api.yaml. +/// Corresponds to the ProcessRequest schema defined in radiologists-extensibility-api.yaml. /// The envelope allows additional named inputs beyond and /// — they are surfaced via . /// diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessResponse.cs b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/ProcessResponse.cs similarity index 95% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessResponse.cs rename to radiologists/src/models/Dragon.Copilot.Radiologists.Models/ProcessResponse.cs index 84f6d41..ab756b6 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/ProcessResponse.cs +++ b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/ProcessResponse.cs @@ -1,12 +1,12 @@ using System.Text.Json.Serialization; -namespace Dragon.Copilot.Radiology.Models; +namespace Dragon.Copilot.Radiologists.Models; /// /// Response envelope for the /v1/process endpoint. /// /// -/// Corresponds to the ProcessResponse schema defined in radiology-extensibility-api.yaml. +/// Corresponds to the ProcessResponse schema defined in radiologists-extensibility-api.yaml. /// The is a map of named outputs (e.g., "qualityCheckResult"), each value /// being a . Output names are declared in the extension's manifest. /// diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/Provenance.cs b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/Provenance.cs similarity index 93% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/Provenance.cs rename to radiologists/src/models/Dragon.Copilot.Radiologists.Models/Provenance.cs index b592566..925ef41 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/Provenance.cs +++ b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/Provenance.cs @@ -1,12 +1,12 @@ using System.Text.Json.Serialization; -namespace Dragon.Copilot.Radiology.Models +namespace Dragon.Copilot.Radiologists.Models { /// /// Identifies a section in the report that was used to generate a recommendation. /// /// - /// Corresponds to the Provenance schema defined in radiology-extensibility-api.yaml. + /// Corresponds to the Provenance schema defined in radiologists-extensibility-api.yaml. /// public class Provenance { diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/QualityCheckResult.cs b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/QualityCheckResult.cs similarity index 95% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/QualityCheckResult.cs rename to radiologists/src/models/Dragon.Copilot.Radiologists.Models/QualityCheckResult.cs index dd7cfbe..ef46ad5 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/QualityCheckResult.cs +++ b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/QualityCheckResult.cs @@ -2,13 +2,13 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -namespace Dragon.Copilot.Radiology.Models +namespace Dragon.Copilot.Radiologists.Models { /// /// Represents the quality check result payload containing billing and clinical recommendations. /// /// - /// Corresponds to the QualityCheckResult schema defined in radiology-extensibility-api.yaml. + /// Corresponds to the QualityCheckResult schema defined in radiologists-extensibility-api.yaml. /// The recommendations list will be empty if there are no recommendations. /// /// diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/QualityCheckType.cs b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/QualityCheckType.cs similarity index 90% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/QualityCheckType.cs rename to radiologists/src/models/Dragon.Copilot.Radiologists.Models/QualityCheckType.cs index 68804a9..3ad9d03 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/QualityCheckType.cs +++ b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/QualityCheckType.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Dragon.Copilot.Radiology.Models +namespace Dragon.Copilot.Radiologists.Models { /// /// The type of quality check. diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/Recommendation.cs b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/Recommendation.cs similarity index 97% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/Recommendation.cs rename to radiologists/src/models/Dragon.Copilot.Radiologists.Models/Recommendation.cs index 7887caf..801abdb 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/Recommendation.cs +++ b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/Recommendation.cs @@ -2,13 +2,13 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -namespace Dragon.Copilot.Radiology.Models +namespace Dragon.Copilot.Radiologists.Models { /// /// A quality check recommendation produced by an extension. /// /// - /// Corresponds to the Recommendation schema defined in radiology-extensibility-api.yaml. + /// Corresponds to the Recommendation schema defined in radiologists-extensibility-api.yaml. /// /// /// diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/ReferenceResource.cs b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/ReferenceResource.cs similarity index 90% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/ReferenceResource.cs rename to radiologists/src/models/Dragon.Copilot.Radiologists.Models/ReferenceResource.cs index 6314924..b40ec7c 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/ReferenceResource.cs +++ b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/ReferenceResource.cs @@ -1,12 +1,12 @@ using System.Text.Json.Serialization; -namespace Dragon.Copilot.Radiology.Models +namespace Dragon.Copilot.Radiologists.Models { /// /// A reference resource that helps understand the recommendation. /// /// - /// Corresponds to the ReferenceResource schema defined in radiology-extensibility-api.yaml. + /// Corresponds to the ReferenceResource schema defined in radiologists-extensibility-api.yaml. /// Examples include a website URL, PDF document name, or chat prompt. /// public class ReferenceResource diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/Report.cs b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/Report.cs similarity index 80% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/Report.cs rename to radiologists/src/models/Dragon.Copilot.Radiologists.Models/Report.cs index ed97a8b..f1bb066 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/Report.cs +++ b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/Report.cs @@ -1,13 +1,13 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -namespace Dragon.Copilot.Radiology.Models; +namespace Dragon.Copilot.Radiologists.Models; /// /// Radiology report text payload. /// /// -/// Corresponds to the Report schema defined in radiology-extensibility-api.yaml. +/// Corresponds to the Report schema defined in radiologists-extensibility-api.yaml. /// /// /// diff --git a/radiology/src/models/Dragon.Copilot.Radiology.Models/SessionData.cs b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/SessionData.cs similarity index 79% rename from radiology/src/models/Dragon.Copilot.Radiology.Models/SessionData.cs rename to radiologists/src/models/Dragon.Copilot.Radiologists.Models/SessionData.cs index bac9361..bfa876b 100644 --- a/radiology/src/models/Dragon.Copilot.Radiology.Models/SessionData.cs +++ b/radiologists/src/models/Dragon.Copilot.Radiologists.Models/SessionData.cs @@ -1,16 +1,16 @@ using System; using System.Text.Json.Serialization; -namespace Dragon.Copilot.Radiology.Models; +namespace Dragon.Copilot.Radiologists.Models; /// /// Session context for request correlation and tracking. /// /// -/// Corresponds to the SessionData schema defined in radiology-extensibility-api.yaml. +/// Corresponds to the SessionData schema defined in radiologists-extensibility-api.yaml. /// JSON property names on this type are snake_case (e.g. correlation_id) by design, -/// inherited from the upstream Dragon SessionData contract. The rest of the radiology -/// extensibility schemas use camelCase. +/// inherited from the upstream Dragon SessionData contract. The rest of the schemas in the +/// extensibility API for Dragon Copilot (radiologists) use camelCase. /// public class SessionData { diff --git a/radiologists/src/samples/Workflow/README.md b/radiologists/src/samples/Workflow/README.md new file mode 100644 index 0000000..68fd63e --- /dev/null +++ b/radiologists/src/samples/Workflow/README.md @@ -0,0 +1,48 @@ +[← Radiologists product overview](../../../README.md) + +# Dragon Copilot — Radiologists Extension Samples (Workflow) + +This folder contains ASP.NET Core sample projects that demonstrate the +partner extension pattern for Dragon Copilot. + +| Project | Purpose | Default port (http/https) | Target | +| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------- | +| [`SampleExtension.Radiologists.Web.Quickstart`](./SampleExtension.Radiologists.Web.Quickstart/README.md) | Returns a canned response loaded from `MockData/qualitycheck-response.json`. No model inference, no AI dependencies. | 5080 / 7080 | `net10.0` (cross-platform) | +| [`SampleExtension.Radiologists.Web.Ai`](./SampleExtension.Radiologists.Web.Ai/README.md) | Uses Azure OpenAI when configured, falls back to an on-device Foundry Local model otherwise. | 5080 / 7080 | `net10.0-windows10.0.26100` (Windows-only, required by Foundry Local) | + +## Solution + +[`SampleExtension.Radiologists.Web.slnx`](./SampleExtension.Radiologists.Web.slnx) +contains the sample projects plus the shared +[`Dragon.Copilot.Radiologists.Models`](../../models/Dragon.Copilot.Radiologists.Models/Dragon.Copilot.Radiologists.Models.csproj) +contract project. + +## Build everything + +```powershell +dotnet build SampleExtension.Radiologists.Web.slnx +``` + +## Run a sample + +```powershell +# Stub-only (mock data) +dotnet run --project SampleExtension.Radiologists.Web.Quickstart + +# AI-backed (Azure OpenAI + Foundry Local fallback) +dotnet run --project SampleExtension.Radiologists.Web.Ai +``` + +## Samples in other languages + +These samples are written in C#, but the same wire contract works in any +language. To scaffold an equivalent sample in Python, Go, Java, Node.js, +TypeScript, or Rust, use the reusable Copilot prompt: + +1. Open the repo in VS Code with GitHub Copilot Chat enabled. +2. In Copilot Chat, type `/` and select `radiologists-scaffold-language-sample`. +3. Provide the target language when prompted, then review and run the generated sample. + +The prompt lives at +[`.github/prompts/radiologists-scaffold-language-sample.prompt.md`](../../../../.github/prompts/radiologists-scaffold-language-sample.prompt.md) +and mirrors the C# Quickstart's contract, structure, and tests. diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/AuthenticationOptions.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/AuthenticationOptions.cs similarity index 94% rename from radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/AuthenticationOptions.cs rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/AuthenticationOptions.cs index 84d93d1..2cc90d5 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/AuthenticationOptions.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/AuthenticationOptions.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace SampleExtension.Radiology.Web.Ai.Configuration; +namespace SampleExtension.Radiologists.Web.Ai.Configuration; /// /// JWT Authentication configuration options. diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/FoundryLocalSettings.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/FoundryLocalSettings.cs similarity index 94% rename from radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/FoundryLocalSettings.cs rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/FoundryLocalSettings.cs index 288d7dc..c2334b9 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/FoundryLocalSettings.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/FoundryLocalSettings.cs @@ -1,4 +1,4 @@ -namespace SampleExtension.Radiology.Web.Ai.Configuration; +namespace SampleExtension.Radiologists.Web.Ai.Configuration; /// /// Settings for Microsoft.AI.Foundry.Local on-device model inference. @@ -30,7 +30,7 @@ public class FoundryLocalSettings /// /// Application name passed to FoundryLocalManager. Used for log/data directory naming. /// - public string AppName { get; set; } = "DragonCopilot-Radiology-Sample"; + public string AppName { get; set; } = "DragonCopilot-Radiologists-Sample"; /// /// Local directory used by Foundry Local for the model cache and logs. diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/OpenAiSettings.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/OpenAiSettings.cs similarity index 91% rename from radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/OpenAiSettings.cs rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/OpenAiSettings.cs index 8e6d476..eebd9b5 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Configuration/OpenAiSettings.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/OpenAiSettings.cs @@ -1,4 +1,4 @@ -namespace SampleExtension.Radiology.Web.Ai.Configuration; +namespace SampleExtension.Radiologists.Web.Ai.Configuration; /// /// Settings for OpenAI configuration. diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Controllers/QualityCheckController.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Controllers/QualityCheckController.cs similarity index 91% rename from radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Controllers/QualityCheckController.cs rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Controllers/QualityCheckController.cs index 80c99fb..b199d12 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Controllers/QualityCheckController.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Controllers/QualityCheckController.cs @@ -1,13 +1,13 @@ -using Dragon.Copilot.Radiology.Models; +using Dragon.Copilot.Radiologists.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using SampleExtension.Radiology.Web.Ai.Services; +using SampleExtension.Radiologists.Web.Ai.Services; using System.Text.Json; -namespace SampleExtension.Radiology.Web.Ai.Controllers; +namespace SampleExtension.Radiologists.Web.Ai.Controllers; /// -/// Single entry point of the Radiology simple extension. +/// Single entry point of the Radiologists simple extension. /// Demonstrates a single-endpoint extension with model binding /// performed by the framework and no authentication. /// diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/ServiceCollectionExtensions.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/ServiceCollectionExtensions.cs similarity index 96% rename from radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/ServiceCollectionExtensions.cs rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/ServiceCollectionExtensions.cs index 6e7e960..c96d093 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/ServiceCollectionExtensions.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/ServiceCollectionExtensions.cs @@ -6,10 +6,10 @@ using Microsoft.Identity.Web; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; -using SampleExtension.Radiology.Web.Ai.Configuration; -using AuthOptions = SampleExtension.Radiology.Web.Ai.Configuration.AuthenticationOptions; +using SampleExtension.Radiologists.Web.Ai.Configuration; +using AuthOptions = SampleExtension.Radiologists.Web.Ai.Configuration.AuthenticationOptions; -namespace SampleExtension.Radiology.Web.Ai.Extensions; +namespace SampleExtension.Radiologists.Web.Ai.Extensions; /// /// Extension methods for service collection configuration. diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/WebApplicationExtensions.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/WebApplicationExtensions.cs similarity index 93% rename from radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/WebApplicationExtensions.cs rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/WebApplicationExtensions.cs index 89bfd20..c4a2b59 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Extensions/WebApplicationExtensions.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/WebApplicationExtensions.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace SampleExtension.Radiology.Web.Ai.Extensions; +namespace SampleExtension.Radiologists.Web.Ai.Extensions; /// /// Extension methods for configuring the web application security pipeline. diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Program.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Program.cs similarity index 86% rename from radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Program.cs rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Program.cs index 75b3503..f424b11 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Program.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Program.cs @@ -1,10 +1,10 @@ -// Minimal, self-contained Radiology extension sample. +// Minimal, self-contained Radiologists extension sample. // Partners can copy this project folder and run it with `dotnet run`. using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using SampleExtension.Radiology.Web.Ai.Configuration; -using SampleExtension.Radiology.Web.Ai.Extensions; -using SampleExtension.Radiology.Web.Ai.Services; +using SampleExtension.Radiologists.Web.Ai.Configuration; +using SampleExtension.Radiologists.Web.Ai.Extensions; +using SampleExtension.Radiologists.Web.Ai.Services; using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); @@ -38,9 +38,9 @@ { c.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo { - Title = "Simple Radiology Extension API", + Title = "Simple Radiologists Extension API", Version = "v1", - Description = "A simple radiology extension sample that demonstrates the extension pattern for Dragon Copilot." + Description = "A simple radiologists extension sample that demonstrates the extension pattern for Dragon Copilot." }); }); builder.Services.AddHealthChecks(); @@ -63,7 +63,7 @@ app.UseSwagger(); app.UseSwaggerUI(c => { - c.SwaggerEndpoint("/swagger/v1/swagger.json", "Simple Radiology Extension API v1"); + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Simple Radiologists Extension API v1"); c.RoutePrefix = string.Empty; // Serve Swagger UI at the app's root. }); } diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Properties/launchSettings.json b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Properties/launchSettings.json similarity index 100% rename from radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/Properties/launchSettings.json rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Properties/launchSettings.json diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/README.md b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/README.md similarity index 90% rename from radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/README.md rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/README.md index 2e8eaf6..8bfb4be 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/README.md +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/README.md @@ -1,6 +1,6 @@ # Partner Extension Sample — AI -A radiology extension for Dragon Copilot that performs **AI-powered** +A radiologists extension for Dragon Copilot that performs **AI-powered** quality checks. This sample wires up Azure OpenAI for cloud inference and falls back to an on-device Foundry Local model when Azure OpenAI is not configured. Use it as the starting point for partners that need @@ -18,17 +18,17 @@ that follows the expected contract. ## API endpoints -| Method | Route | Auth | Description | -| ------ | --------------------- | ----------- | ----------------------------------------------------- | -| POST | `/v1/process` | JWT | Analyzes a radiology report, returns quality checks | -| GET | `/health/liveness` | Public | Liveness probe, returns `{"status":"Healthy"}` | -| GET | `/health/readiness` | Public | Readiness probe, returns `{"status":"Healthy"}` | -| GET | `/` | Public | Swagger UI (Development only) | +| Method | Route | Auth | Description | +| ------ | ------------------- | ------ | --------------------------------------------------- | +| POST | `/v1/process` | JWT | Analyzes a radiology report, returns quality checks | +| GET | `/health/liveness` | Public | Liveness probe, returns `{"status":"Healthy"}` | +| GET | `/health/readiness` | Public | Readiness probe, returns `{"status":"Healthy"}` | +| GET | `/` | Public | Swagger UI (Development only) | ## Run locally ```powershell -dotnet run --project SampleExtension.Radiology.Web.Ai +dotnet run --project SampleExtension.Radiologists.Web.Ai ``` Available endpoints: @@ -36,7 +36,7 @@ Available endpoints: - Swagger UI: http://localhost:5080/ - Health: `/health/liveness`, `/health/readiness` -A `.http` file (`SampleExtension.Radiology.Web.Ai.http`) is included for +A `.http` file (`SampleExtension.Radiologists.Web.Ai.http`) is included for sending sample requests from Visual Studio or VS Code. ## Security @@ -182,7 +182,7 @@ Subsequent requests reuse the loaded model and respond quickly. ## Request / response contract -See [`radiology-extensibility-api.yaml`](../../../../radiology-extensibility-api.yaml) for the full OpenAPI spec. +See [`radiologists-extensibility-api.yaml`](../../../../radiologists-extensibility-api.yaml) for the full OpenAPI spec. Only `sessionData` is required. `extensibilityApiVersion` shows which Dragon Copilot API version sent the request, and your extension does not need to read it. Extra fields are accepted, so your extension keeps working as the API evolves. diff --git a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.csproj b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.csproj similarity index 87% rename from radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.csproj rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.csproj index bb98752..a24692b 100644 --- a/radiology/src/samples/Workflow/SampleExtension.Radiology.Web.Ai/SampleExtension.Radiology.Web.Ai.csproj +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.csproj @@ -5,8 +5,8 @@ win-x64 enable enable - SampleExtension.Radiology.Web.Ai - SampleExtension.Radiology.Web.Ai + SampleExtension.Radiologists.Web.Ai + SampleExtension.Radiologists.Web.Ai true diff --git a/radiologists/src/samples/Workflow/README.md b/radiologists/src/samples/Workflow/README.md index 68fd63e..fe9cece 100644 --- a/radiologists/src/samples/Workflow/README.md +++ b/radiologists/src/samples/Workflow/README.md @@ -5,10 +5,11 @@ This folder contains ASP.NET Core sample projects that demonstrate the partner extension pattern for Dragon Copilot. -| Project | Purpose | Default port (http/https) | Target | -| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------- | -| [`SampleExtension.Radiologists.Web.Quickstart`](./SampleExtension.Radiologists.Web.Quickstart/README.md) | Returns a canned response loaded from `MockData/qualitycheck-response.json`. No model inference, no AI dependencies. | 5080 / 7080 | `net10.0` (cross-platform) | -| [`SampleExtension.Radiologists.Web.Ai`](./SampleExtension.Radiologists.Web.Ai/README.md) | Uses Azure OpenAI when configured, falls back to an on-device Foundry Local model otherwise. | 5080 / 7080 | `net10.0-windows10.0.26100` (Windows-only, required by Foundry Local) | +| Project | Purpose | Default port (http/https) | Target | +| ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------- | +| [`SampleExtension.Radiologists.Web.Quickstart`](./SampleExtension.Radiologists.Web.Quickstart/README.md) | Returns a canned response loaded from `MockData/qualitycheck-response.json`. No model inference, no AI dependencies. | 5080 / 7080 | `net10.0` (cross-platform) | +| [`SampleExtension.Radiologists.Web.Ai`](./SampleExtension.Radiologists.Web.Ai/README.md) | AI-powered quality checks via **Azure OpenAI** (cloud). | 5080 / 7080 | `net10.0` (cross-platform) | +| [`SampleExtension.Radiologists.Web.FoundryLocal`](./SampleExtension.Radiologists.Web.FoundryLocal/README.md) | AI-powered quality checks via **Foundry Local** (on-device). | 5080 / 7080 | `net10.0-windows10.0.26100` (**Windows-only**, Foundry Local / WinML) | ## Solution @@ -29,8 +30,11 @@ dotnet build SampleExtension.Radiologists.Web.slnx # Stub-only (mock data) dotnet run --project SampleExtension.Radiologists.Web.Quickstart -# AI-backed (Azure OpenAI + Foundry Local fallback) +# AI-backed, cross-platform (Azure OpenAI) dotnet run --project SampleExtension.Radiologists.Web.Ai + +# AI-backed, on-device (Foundry Local — Windows-only) +dotnet run --project SampleExtension.Radiologists.Web.FoundryLocal ``` ## Samples in other languages diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/AuthenticationOptions.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/AuthenticationOptions.cs index 2cc90d5..05bf4b2 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/AuthenticationOptions.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/AuthenticationOptions.cs @@ -1,6 +1,3 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - namespace SampleExtension.Radiologists.Web.Ai.Configuration; /// diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Controllers/QualityCheckController.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Controllers/QualityCheckController.cs index 07a3d66..3237ed8 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Controllers/QualityCheckController.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Controllers/QualityCheckController.cs @@ -33,13 +33,13 @@ public QualityCheckController(IQualityCheckService qualityCheckService, ILogger< /// Analyzes a radiology report and returns a list of quality-check recommendations. /// /// - /// This sample uses Azure OpenAI when configured, falling back to an on-device - /// Foundry Local model. Replace with - /// your real implementation. + /// This sample uses Azure OpenAI. Replace + /// with your real implementation. /// [HttpPost("process")] [ProducesResponseType(typeof(ProcessResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> PostAsync([FromBody] ProcessRequest payload, CancellationToken cancellationToken) { @@ -50,6 +50,14 @@ public async Task> PostAsync([FromBody] ProcessReq return BadRequest(ModelState); } + if (!_qualityCheckService.IsConfigured) + { + return Problem( + statusCode: StatusCodes.Status503ServiceUnavailable, + title: "Azure OpenAI is not configured", + detail: "Set the OpenAI Endpoint, ApiKey, and DeploymentName in appsettings.json and restart the app."); + } + _logger.LogInformation( "Received {Method} {Path} - CorrelationId={CorrelationId}", Request.Method, diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Dockerfile b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Dockerfile new file mode 100644 index 0000000..cfb6cb4 --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Dockerfile @@ -0,0 +1,29 @@ +# Radiologists AI sample (cross-platform, Azure OpenAI). +# The build context is the repository root, e.g.: +# docker build -f radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Dockerfile -t radiologists-ai . +# +# The image builds without any credentials. To call a real model at runtime, supply the +# Azure OpenAI settings as environment variables, e.g.: +# docker run -p 8080:8080 \ +# -e OpenAI__Endpoint=https://.openai.azure.com/ \ +# -e OpenAI__ApiKey= \ +# -e OpenAI__DeploymentName= \ +# radiologists-ai +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy the whole repository so Central Package Management (Directory.Packages.props) +# and Directory.Build.props resolve during restore. +COPY . . + +RUN dotnet publish "radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.csproj" \ + -c Release -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "SampleExtension.Radiologists.Web.Ai.dll"] diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/ServiceCollectionExtensions.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/ServiceCollectionExtensions.cs index c96d093..14d4b3d 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/ServiceCollectionExtensions.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,3 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - using System.Net.Http.Headers; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Identity.Web; diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/WebApplicationExtensions.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/WebApplicationExtensions.cs index c4a2b59..7c580a8 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/WebApplicationExtensions.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Extensions/WebApplicationExtensions.cs @@ -1,6 +1,3 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - namespace SampleExtension.Radiologists.Web.Ai.Extensions; /// diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Program.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Program.cs index f424b11..3f52fd7 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Program.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Program.cs @@ -12,12 +12,8 @@ // OpenAI configuration builder.Services.Configure(builder.Configuration.GetSection(OpenAiSettings.SectionName)); -// Foundry Local (on-device model) configuration. Used as a fallback when Azure OpenAI is not configured. -builder.Services.Configure(builder.Configuration.GetSection(FoundryLocalSettings.SectionName)); - // Services builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); // JWT authentication (Microsoft Entra ID). diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/README.md b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/README.md index 8bfb4be..eb8636d 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/README.md +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/README.md @@ -1,18 +1,22 @@ -# Partner Extension Sample — AI +# Partner Extension Sample — AI (Azure OpenAI) A radiologists extension for Dragon Copilot that performs **AI-powered** -quality checks. This sample wires up Azure OpenAI for cloud inference -and falls back to an on-device Foundry Local model when Azure OpenAI -is not configured. Use it as the starting point for partners that need -real model inference; copy the project, replace the prompt and result -handling with your own implementation, and deploy a working extension -that follows the expected contract. +quality checks via **Azure OpenAI**. This sample is **cross-platform** +(`net10.0`) and is the real-model example partners can build and run on +Windows, Linux, or macOS. Use it as the starting point for partners that +need real model inference; copy the project, replace the prompt and result +handling with your own implementation, and deploy a working extension that +follows the expected contract. + +> **Want on-device inference instead?** See the +> [`SampleExtension.Radiologists.Web.FoundryLocal`](../SampleExtension.Radiologists.Web.FoundryLocal/README.md) +> sample, which runs a local model via Foundry Local (**Windows-only**). ## What's included -- ASP.NET Core Web API (.NET 10, Windows-only because of Foundry Local), single controller: `POST /v1/process` +- ASP.NET Core Web API (.NET 10, **cross-platform**), single controller: `POST /v1/process` - JWT authentication via Microsoft Entra ID, toggleable via `Authentication.Enabled` in `appsettings.json` -- AI-powered quality checks via Azure OpenAI **or** an on-device model through Microsoft.AI.Foundry.Local +- AI-powered quality checks via **Azure OpenAI** - Swagger UI at the app root in Development - Health probes at `/health/liveness` and `/health/readiness` (JSON responses) @@ -106,20 +110,18 @@ Authorization: Bearer ## Quality check provider -The extension performs the AI-powered quality check using one of two providers, -selected automatically in this priority order: +The extension performs the AI-powered quality check using **Azure OpenAI**, +configured via the `OpenAI` section in `appsettings.json` (`Endpoint`, `ApiKey`, +and `DeploymentName`). -1. **Azure OpenAI** — used when `OpenAI.Endpoint`, `OpenAI.ApiKey`, and - `OpenAI.DeploymentName` are all set in `appsettings.json`. -2. **Foundry Local** — used when Azure OpenAI is not configured and - `FoundryLocal.Enabled` is `true`. Runs an on-device model with no cloud - calls. Enabled by default so the sample runs out-of-the-box (the model - downloads on first use). +If Azure OpenAI is not configured, the extension responds with +`503 Service Unavailable` and a message naming the settings to populate — by +design, so a misconfigured deployment fails fast and visibly rather than +silently returning empty results. -If neither provider is available (Azure OpenAI not configured **and** -`FoundryLocal.Enabled` is `false`), the service throws an -`InvalidOperationException` on the first request — by design, so misconfigured -deployments fail fast rather than silently returning empty results. +> For an on-device alternative that needs no cloud account, see the +> [`SampleExtension.Radiologists.Web.FoundryLocal`](../SampleExtension.Radiologists.Web.FoundryLocal/README.md) +> sample (**Windows-only**). ## Azure OpenAI Configuration @@ -147,38 +149,17 @@ To configure the AI-powered quality check, you need an Azure OpenAI model deploy } ``` -For production deployments, store the API key in a secure location such as -Azure Key Vault or environment variables rather than in `appsettings.json`. - -## Foundry Local (on-device model) Configuration - -Foundry Local runs a small language model directly on the host machine with no -cloud dependency. Requires Windows 10 build 26100 or later. - -Configure in the `FoundryLocal` section of `appsettings.json`: +For production, prefer **environment variables** or a secret store (e.g. Azure +Key Vault) over putting secrets in `appsettings.json`. Each `OpenAI` setting maps +to an environment variable via ASP.NET Core's `__` (double-underscore) convention: -| Setting | Description | -| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Enabled` | When `true`, Foundry Local is used if Azure OpenAI is not configured. Defaults to `true` so the sample runs out-of-the-box. Set to `false` to require Azure OpenAI configuration. | -| `ModelAlias` | Foundry Local model alias to download and load. Defaults to `qwen2.5-1.5b`. Other options: `qwen2.5-0.5b`, `phi-3.5-mini`, `phi-4-mini`, `mistral-7b`, `gpt-oss-20b`. | -| `DeviceType` | `CPU`, `GPU`, or `NPU`. Defaults to `CPU` so the sample runs on machines without a GPU/NPU. | -| `AppName` | Application name passed to Foundry Local; used for log/data directory naming. | -| `AppDataDir` | Local directory for the model cache and logs. Empty (default) uses `%USERPROFILE%\.foundry` so the cache is shared with other Foundry Local apps and tools on the same machine. Set an absolute path to override. | - -### First-run behavior - -On the first request that uses Foundry Local, the configured model is -downloaded into the local cache and loaded into memory before inference runs. -Depending on model size and network speed, this can take from several seconds -to several minutes. - -The first request takes time while the model downloads and loads. You can -send request using an HTTP client tool such as Bruno, or use the included `.http` -file — in that case, raise the request timeout (for example by adding -`# @timeout 600` above the request) so the model has time to download and -load. +```bash +OpenAI__Endpoint=https://.openai.azure.com/ +OpenAI__ApiKey= +OpenAI__DeploymentName= +``` -Subsequent requests reuse the loaded model and respond quickly. +These are the same variables shown in the sample's `Dockerfile`. ## Request / response contract diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.csproj b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.csproj index a24692b..d9e78d2 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.csproj +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.csproj @@ -1,8 +1,7 @@ - net10.0-windows10.0.26100 - win-x64 + net10.0 enable enable SampleExtension.Radiologists.Web.Ai @@ -23,8 +22,6 @@ - - diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.http b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.http index 759f745..1f9bc1e 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.http +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/SampleExtension.Radiologists.Web.Ai.http @@ -7,7 +7,7 @@ GET {{SampleExtension.Radiologists.Web.Ai_HostAddress}}/health/liveness GET {{SampleExtension.Radiologists.Web.Ai_HostAddress}}/health/readiness ### Process a radiology report (happy path) -# @timeout 600 +# @timeout 60 POST {{SampleExtension.Radiologists.Web.Ai_HostAddress}}/v1/process Content-Type: application/json diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/IQualityCheckService.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/IQualityCheckService.cs index ceb6bfc..9c585ee 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/IQualityCheckService.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/IQualityCheckService.cs @@ -5,9 +5,15 @@ namespace SampleExtension.Radiologists.Web.Ai.Services; /// /// Abstraction for the component that turns an incoming radiology report /// into a . In this sample it dispatches to -/// Azure OpenAI or a Foundry Local model. Replace with your own implementation. +/// Azure OpenAI. Replace with your own implementation. /// public interface IQualityCheckService { + /// + /// Whether the underlying provider has the configuration it needs to run — + /// for the Azure OpenAI sample, the endpoint, API key, and deployment name. + /// + bool IsConfigured { get; } + Task ProcessAsync(ProcessRequest payload, CancellationToken cancellationToken = default); } diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/QualityCheckService.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/QualityCheckService.cs index 10a39a2..70e3bf4 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/QualityCheckService.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/QualityCheckService.cs @@ -1,21 +1,18 @@ using System.Text.Json; using Dragon.Copilot.Radiologists.Models; -using Microsoft.Extensions.Options; using OpenAI.Chat; -using SampleExtension.Radiologists.Web.Ai.Configuration; namespace SampleExtension.Radiologists.Web.Ai.Services; /// -/// Quality-check service that selects an inference provider in this priority order: -/// 1. Azure OpenAI (if endpoint, key, and deployment are configured) -/// 2. Foundry Local on-device model (if enabled) -/// If neither is available, throws . +/// Quality-check service backed by Azure OpenAI. Runs inference via the configured +/// Azure OpenAI deployment when Endpoint, ApiKey, and DeploymentName are set; +/// otherwise throws . /// public sealed class QualityCheckService : IQualityCheckService { /// - /// System prompt shared by all model-backed providers (Azure OpenAI and Foundry Local). + /// System prompt used to instruct the Azure OpenAI model. /// private const string SystemPrompt = """ @@ -84,26 +81,21 @@ Output format (JSON) respond with a single JSON object matching this schema exac private readonly ILogger _logger; private readonly IAzureOpenAIService _azureOpenAi; - private readonly IFoundryLocalService _foundryLocal; - private readonly FoundryLocalSettings _foundryLocalSettings; public QualityCheckService( ILogger logger, - IAzureOpenAIService azureOpenAi, - IFoundryLocalService foundryLocal, - IOptions foundryLocalOptions) + IAzureOpenAIService azureOpenAi) { ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(azureOpenAi); - ArgumentNullException.ThrowIfNull(foundryLocal); - ArgumentNullException.ThrowIfNull(foundryLocalOptions); _logger = logger; _azureOpenAi = azureOpenAi; - _foundryLocal = foundryLocal; - _foundryLocalSettings = foundryLocalOptions.Value; } + /// + public bool IsConfigured => _azureOpenAi.IsConfigured; + /// public async Task ProcessAsync(ProcessRequest payload, CancellationToken cancellationToken = default) { @@ -114,25 +106,8 @@ public async Task ProcessAsync(ProcessRequest payload, Cancella payload.SessionData.CorrelationId, payload.Report?.ReportText.Length); - if (_azureOpenAi.IsConfigured) - { - _logger.LogInformation("Using Azure OpenAI provider."); - return await ProcessWithChatClientAsync(payload, _azureOpenAi.GetChatClient(), cancellationToken).ConfigureAwait(false); - } - - if (_foundryLocalSettings.Enabled) - { - _logger.LogInformation( - "Using Foundry Local provider (model={Model}). If this is the first request after startup, the model may need to download and load — this can take several minutes.", - _foundryLocalSettings.ModelAlias); - // GetChatClientAsync is lazily memoized; first call may be slow due to model download/load, - // but awaiting it (rather than blocking) keeps the ASP.NET Core thread-pool free. - var chatClient = await _foundryLocal.GetChatClientAsync(cancellationToken).ConfigureAwait(false); - return await ProcessWithChatClientAsync(payload, chatClient, cancellationToken).ConfigureAwait(false); - } - - throw new InvalidOperationException( - "No model provider configured. Set Azure OpenAI Endpoint/ApiKey/DeploymentName, or set FoundryLocal.Enabled=true in appsettings.json."); + _logger.LogInformation("Using Azure OpenAI provider."); + return await ProcessWithChatClientAsync(payload, _azureOpenAi.GetChatClient(), cancellationToken).ConfigureAwait(false); } private async Task ProcessWithChatClientAsync(ProcessRequest payload, ChatClient chatClient, CancellationToken cancellationToken) diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/appsettings.json b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/appsettings.json index 3fab67a..f42d899 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/appsettings.json +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/appsettings.json @@ -22,12 +22,5 @@ "Endpoint": "", // Azure OpenAI resource endpoint URL "ApiKey": "", // API key for authenticating with Azure OpenAI service "DeploymentName": "" // Name of the deployed model in Azure OpenAI - }, - "FoundryLocal": { - "Enabled": true, // Used only when Azure OpenAI is not configured. When true, runs inference via Foundry Local on-device model. When false and Azure OpenAI is also not configured, the service throws at startup of the first request. - "ModelAlias": "qwen2.5-1.5b", // Alternatives: qwen2.5-0.5b, phi-3.5-mini, phi-4-mini, mistral-7b, gpt-oss-20b. - "DeviceType": "CPU", // CPU works on machines without GPU/NPU. Other values: GPU, NPU - "AppName": "DragonCopilotRadiologistsSample", - "AppDataDir": "" // Model cache + logs location. Empty = %USERPROFILE%\.foundry (shared). Set absolute path to override. } } diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Configuration/AuthenticationOptions.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Configuration/AuthenticationOptions.cs new file mode 100644 index 0000000..de58759 --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Configuration/AuthenticationOptions.cs @@ -0,0 +1,37 @@ +namespace SampleExtension.Radiologists.Web.FoundryLocal.Configuration; + +/// +/// JWT Authentication configuration options. +/// +public class AuthenticationOptions +{ + /// + /// The configuration section name. + /// + public const string SectionName = "Authentication"; + + /// + /// Whether authentication is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Tenant ID for the application. + /// + public string? TenantId { get; set; } + + /// + /// Client ID for the application. + /// + public string? ClientId { get; set; } + + /// + /// Login instance (e.g., "https://login.microsoftonline.com/"). + /// + public string Instance { get; set; } = "https://login.microsoftonline.com/"; + + /// + /// Required claims that must be present in JWT tokens. + /// + public IDictionary> RequiredClaims { get; } = new Dictionary>(); +} diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/FoundryLocalSettings.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Configuration/FoundryLocalSettings.cs similarity index 81% rename from radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/FoundryLocalSettings.cs rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Configuration/FoundryLocalSettings.cs index c2334b9..20b553a 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Configuration/FoundryLocalSettings.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Configuration/FoundryLocalSettings.cs @@ -1,8 +1,7 @@ -namespace SampleExtension.Radiologists.Web.Ai.Configuration; +namespace SampleExtension.Radiologists.Web.FoundryLocal.Configuration; /// /// Settings for Microsoft.AI.Foundry.Local on-device model inference. -/// Used when Azure OpenAI is not configured. /// public class FoundryLocalSettings { @@ -11,11 +10,6 @@ public class FoundryLocalSettings /// public const string SectionName = "FoundryLocal"; - /// - /// When true, the Foundry Local provider is used if Azure OpenAI is not configured. - /// - public bool Enabled { get; set; } = true; - /// /// Foundry Local model alias to download and load. /// Alternatives: qwen2.5-0.5b, phi-3.5-mini, phi-4-mini, mistral-7b, gpt-oss-20b. diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Controllers/QualityCheckController.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Controllers/QualityCheckController.cs new file mode 100644 index 0000000..8492eac --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Controllers/QualityCheckController.cs @@ -0,0 +1,69 @@ +using Dragon.Copilot.Radiologists.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SampleExtension.Radiologists.Web.FoundryLocal.Services; + +namespace SampleExtension.Radiologists.Web.FoundryLocal.Controllers; + +/// +/// Single entry point of the Radiologists simple extension. +/// Demonstrates a single-endpoint extension with model binding performed by +/// the framework, with the endpoint protected by JWT bearer authentication +/// (Microsoft Entra ID) via the "RequiredClaims" authorization policy. +/// +[ApiController] +[Route("v1")] +[Produces("application/json")] +[Authorize(Policy = "RequiredClaims")] +public sealed class QualityCheckController : ControllerBase +{ + private readonly IQualityCheckService _qualityCheckService; + private readonly ILogger _logger; + + public QualityCheckController(IQualityCheckService qualityCheckService, ILogger logger) + { + ArgumentNullException.ThrowIfNull(qualityCheckService); + ArgumentNullException.ThrowIfNull(logger); + + _qualityCheckService = qualityCheckService; + _logger = logger; + } + + /// + /// Analyzes a radiology report and returns a list of quality-check recommendations. + /// + /// + /// This sample runs an on-device Foundry Local model. Replace + /// with your real implementation. + /// + [HttpPost("process")] + [ProducesResponseType(typeof(ProcessResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> PostAsync([FromBody] ProcessRequest payload, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(payload); + + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + _logger.LogInformation( + "Received {Method} {Path} - CorrelationId={CorrelationId}", + Request.Method, + Request.Path, + payload.SessionData.CorrelationId); + + var result = await _qualityCheckService.ProcessAsync(payload, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "Response {Method} {Path} - Success: {Success} - Message: {Message}", + Request.Method, + Request.Path, + result.Success, + result.Message); + + return Ok(result); + } +} diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Extensions/ServiceCollectionExtensions.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..2fa315a --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,157 @@ +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Identity.Web; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using SampleExtension.Radiologists.Web.FoundryLocal.Configuration; +using AuthOptions = SampleExtension.Radiologists.Web.FoundryLocal.Configuration.AuthenticationOptions; + +namespace SampleExtension.Radiologists.Web.FoundryLocal.Extensions; + +/// +/// Extension methods for service collection configuration. +/// +public static class ServiceCollectionExtensions +{ + private static readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + private static readonly ILogger _logger = _loggerFactory.CreateLogger(nameof(ServiceCollectionExtensions)); + + /// + /// Adds custom JWT authentication and claims-based authorization services. + /// + public static IServiceCollection AddCustomAuthentication(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + var authOptions = configuration.GetSection(AuthOptions.SectionName).Get(); + + // If authentication is disabled, add policies that always allow access + if (authOptions?.Enabled != true) + { + _logger.LogWarning("JWT authentication is disabled. All requests will be allowed without token validation."); + + services.AddAuthorization(options => + { + options.AddPolicy("RequiredClaims", policy => + policy.RequireAssertion(_ => true)); + }); + + return services; + } + + // Add JWT authentication + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(configuration.GetSection(AuthOptions.SectionName)); + + // Configure JWT Bearer events for diagnostics logging + services.Configure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.Events = new JwtBearerEvents + { + OnAuthenticationFailed = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + + if (context.Exception is SecurityTokenInvalidAudienceException audienceException) + { + var authHeader = context.Request.Headers["Authorization"].ToString(); + string? token = null; + + if (!string.IsNullOrEmpty(authHeader) && + AuthenticationHeaderValue.TryParse(authHeader, out var headerValue) && + string.Equals(headerValue.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase)) + { + token = headerValue.Parameter; + } + + if (!string.IsNullOrEmpty(token)) + { + try + { + var handler = new JsonWebTokenHandler(); + var jsonToken = handler.ReadJsonWebToken(token); + var actualAudience = jsonToken.GetClaim("aud")?.Value ?? "null"; + var expectedAudience = authOptions.ClientId ?? "null"; + + logger.LogWarning( + audienceException, + "JWT audience validation failed. Actual={ActualAudience}, Expected={ExpectedAudience}, Message={Message}", + actualAudience, + expectedAudience, + audienceException.Message); + } + catch (ArgumentException ex) + { + logger.LogWarning(ex, "Failed to parse JWT token for diagnostics: {Message}", ex.Message); + } + } + } + else + { + logger.LogWarning(context.Exception, "JWT authentication failed: {Message}", context.Exception.Message); + } + + return Task.CompletedTask; + }, + OnTokenValidated = context => + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + var principal = context.Principal; + + if (principal?.Identity?.IsAuthenticated == true) + { + logger.LogDebug("JWT token validated successfully."); + + if (authOptions.RequiredClaims.Count != 0) + { + foreach (var requiredClaim in authOptions.RequiredClaims) + { + var actualValues = principal.Claims + .Where(c => c.Type == requiredClaim.Key) + .Select(c => c.Value); + logger.LogDebug( + "Claim validation: {ClaimType} expected=[{Expected}] actual=[{Actual}]", + requiredClaim.Key, + string.Join(", ", requiredClaim.Value), + string.Join(", ", actualValues)); + } + } + } + + return Task.CompletedTask; + }, + }; + }); + + // Add authorization with custom policies + services.AddAuthorization(options => + { + options.AddPolicy("RequiredClaims", policy => + { + policy.RequireAuthenticatedUser(); + + if (authOptions.RequiredClaims.Count != 0) + { + foreach (var claim in authOptions.RequiredClaims) + { + policy.RequireClaim(claim.Key, claim.Value); + } + } + }); + }); + + return services; + } + + /// + /// Registers configuration option classes for authentication. + /// + public static IServiceCollection AddSecurityOptions(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + services.Configure(configuration.GetSection(AuthOptions.SectionName)); + + return services; + } +} diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Extensions/WebApplicationExtensions.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Extensions/WebApplicationExtensions.cs new file mode 100644 index 0000000..bae14de --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,25 @@ +namespace SampleExtension.Radiologists.Web.FoundryLocal.Extensions; + +/// +/// Extension methods for configuring the web application security pipeline. +/// +internal static class WebApplicationExtensions +{ + private static readonly string[] PublicRoutes = ["/health", "/v1/health", "/index.html"]; + + /// + /// Applies JWT authentication and authorization to all non-public routes. + /// + internal static WebApplication UseFullSecurity(this WebApplication app) + { + app.UseWhen( + context => !PublicRoutes.Any(r => (context.Request.Path.Value ?? string.Empty).StartsWith(r, StringComparison.OrdinalIgnoreCase)), + protectedBranch => + { + protectedBranch.UseAuthentication(); + protectedBranch.UseAuthorization(); + }); + + return app; + } +} diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Program.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Program.cs new file mode 100644 index 0000000..766712f --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Program.cs @@ -0,0 +1,86 @@ +// Minimal, self-contained Radiologists extension sample (on-device Foundry Local, Windows-only). +// Partners can copy this project folder and run it with `dotnet run`. + +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using SampleExtension.Radiologists.Web.FoundryLocal.Configuration; +using SampleExtension.Radiologists.Web.FoundryLocal.Extensions; +using SampleExtension.Radiologists.Web.FoundryLocal.Services; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +// Foundry Local (on-device model) configuration. +builder.Services.Configure(builder.Configuration.GetSection(FoundryLocalSettings.SectionName)); + +// Services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// JWT authentication (Microsoft Entra ID). +// Toggle on/off via the "Authentication" config section. +builder.Services.AddCustomAuthentication(builder.Configuration); +builder.Services.AddSecurityOptions(builder.Configuration); + +builder.Services + .AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + }); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo + { + Title = "Radiologists Extension API (Foundry Local, Windows-only)", + Version = "v1", + Description = "A radiologists extension sample that runs on-device inference via Foundry Local (Windows-only)." + }); +}); +builder.Services.AddHealthChecks(); + +// CORS is fully open here for easy local testing. Make sure to restrict this for production. +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => policy + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader()); +}); + +var app = builder.Build(); + +app.UseCors(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Radiologists Extension API (Foundry Local) v1"); + c.RoutePrefix = string.Empty; // Serve Swagger UI at the app's root. + }); +} + +// Health probes return a JSON body (e.g. {"status":"Healthy"}) so monitoring +// tools can parse the result rather than reading a plain-text string. +var healthCheckOptions = new HealthCheckOptions +{ + ResponseWriter = async (context, report) => + { + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(new { status = report.Status.ToString() }).ConfigureAwait(false); + }, +}; + +app.MapHealthChecks("/health/liveness", healthCheckOptions); +app.MapHealthChecks("/health/readiness", healthCheckOptions); + +// Apply JWT authentication to all non-public routes +app.UseFullSecurity(); + +app.MapControllers(); + +app.Run(); diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Properties/launchSettings.json b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Properties/launchSettings.json new file mode 100644 index 0000000..7ecfe2c --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7080;http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/README.md b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/README.md new file mode 100644 index 0000000..ab0bad7 --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/README.md @@ -0,0 +1,73 @@ +# Partner Extension Sample — Foundry Local (on-device, Windows-only) + +A radiologists extension for Dragon Copilot that performs **AI-powered** quality +checks using an **on-device model via [Foundry Local](https://learn.microsoft.com/azure/ai-foundry/foundry-local/)** +— no cloud account or API key required. Inference runs locally through +`Microsoft.AI.Foundry.Local.WinML` (Windows ML). + +> ⚠️ **Windows-only.** This sample targets `net10.0-windows10.0.26100` / `win-x64` +> because of the `Microsoft.AI.Foundry.Local.WinML` dependency. For a +> **cross-platform** real-model sample (Windows, Linux, macOS), use the +> [`SampleExtension.Radiologists.Web.Ai`](../SampleExtension.Radiologists.Web.Ai/README.md) +> sample, which uses Azure OpenAI. + +Use it as the starting point for partners that want to explore on-device inference; +copy the project and replace the prompt and result handling with your own +implementation to run a contract-compliant quality check entirely on local hardware. + +## What's included + +- ASP.NET Core Web API (.NET 10, **Windows-only**), single controller: `POST /v1/process` +- JWT authentication via Microsoft Entra ID, toggleable via `Authentication.Enabled` in `appsettings.json` +- AI-powered quality checks via an **on-device Foundry Local model** (`Microsoft.AI.Foundry.Local.WinML`) +- Swagger UI at the app root in Development +- Health probes at `/health/liveness` and `/health/readiness` (JSON responses) + +## API endpoints + +| Method | Route | Auth | Description | +| ------ | ------------------- | ------ | --------------------------------------------------- | +| POST | `/v1/process` | JWT | Analyzes a radiology report, returns quality checks | +| GET | `/health/liveness` | Public | Liveness probe, returns `{"status":"Healthy"}` | +| GET | `/health/readiness` | Public | Readiness probe, returns `{"status":"Healthy"}` | +| GET | `/` | Public | Swagger UI (Development only) | + +## Run locally (Windows) + +```powershell +dotnet run --project SampleExtension.Radiologists.Web.FoundryLocal +``` + +On the first request the configured model is downloaded and loaded, which can take +several minutes; subsequent requests reuse the loaded model. + +Available endpoints: + +- Swagger UI: http://localhost:5080/ +- Health: `/health/liveness`, `/health/readiness` + +A `.http` file (`SampleExtension.Radiologists.Web.FoundryLocal.http`) is included for +sending sample requests from Visual Studio or VS Code. + +## Foundry Local provider + +The extension runs the quality check on an on-device model via Foundry Local, +configured in the `FoundryLocal` section of `appsettings.json`: + +| Setting | Description | +| ------------ | ------------------------------------------------------------------------------ | +| `ModelAlias` | Foundry Local model to download and load (e.g. `qwen2.5-1.5b`). | +| `DeviceType` | Inference device: `CPU` (default), `GPU`, or `NPU`. | +| `AppName` | Application name passed to Foundry Local (used for log/data directory naming). | +| `AppDataDir` | Model cache + logs location. Empty defaults to `%USERPROFILE%\.foundry`. | + +No cloud account, endpoint, or API key is required — all inference is local. + +## Security + +The application validates JWT bearer tokens on all routes except health probes and +Swagger UI. Authentication is configurable via `appsettings.json` and is **disabled +by default** for local development. See [`appsettings.json`](./appsettings.json) for +the full schema and inline comments, and enable it for production by setting +`Authentication.Enabled` to `true` and populating `TenantId`, `ClientId`, and +`RequiredClaims.azp`. diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/SampleExtension.Radiologists.Web.FoundryLocal.csproj b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/SampleExtension.Radiologists.Web.FoundryLocal.csproj new file mode 100644 index 0000000..fda0af2 --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/SampleExtension.Radiologists.Web.FoundryLocal.csproj @@ -0,0 +1,34 @@ + + + + net10.0-windows10.0.26100 + win-x64 + enable + enable + SampleExtension.Radiologists.Web.FoundryLocal + SampleExtension.Radiologists.Web.FoundryLocal + true + + CA1812;CA1515;CA1848;CA1873;CS1591;CA2227 + + + + + + + + + + + + + + + + + diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/SampleExtension.Radiologists.Web.FoundryLocal.http b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/SampleExtension.Radiologists.Web.FoundryLocal.http new file mode 100644 index 0000000..e9e6f43 --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/SampleExtension.Radiologists.Web.FoundryLocal.http @@ -0,0 +1,28 @@ +@SampleExtension.Radiologists.Web.FoundryLocal_HostAddress = http://localhost:5080 + +### Liveness probe +GET {{SampleExtension.Radiologists.Web.FoundryLocal_HostAddress}}/health/liveness + +### Readiness probe +GET {{SampleExtension.Radiologists.Web.FoundryLocal_HostAddress}}/health/readiness + +### Process a radiology report (happy path) +# @timeout 600 +POST {{SampleExtension.Radiologists.Web.FoundryLocal_HostAddress}}/v1/process +Content-Type: application/json + +{ + "extensibilityApiVersion": "1.1.1", + "sessionData": { + "correlation_id": "11111111-2222-3333-4444-555555555555", + "session_start": "2025-01-01T10:00:00Z", + "environment_id": "local-dev" + }, + "patientInformation": { + "dateOfBirth": "1980-05-12", + "biologicalSex": "Female" + }, + "report": { + "reportText": "CT ABDOMEN WITH CONTRAST: The liver demonstrates paddock steatosis. Chest X-ray performed with for views shows clear lung fields." + } +} diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/FoundryLocalService.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Services/FoundryLocalService.cs similarity index 98% rename from radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/FoundryLocalService.cs rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Services/FoundryLocalService.cs index f19666b..ec37b24 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/FoundryLocalService.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Services/FoundryLocalService.cs @@ -4,10 +4,10 @@ using Microsoft.Extensions.Options; using OpenAI; using OpenAI.Chat; -using SampleExtension.Radiologists.Web.Ai.Configuration; +using SampleExtension.Radiologists.Web.FoundryLocal.Configuration; using FoundryConfiguration = Microsoft.AI.Foundry.Local.Configuration; -namespace SampleExtension.Radiologists.Web.Ai.Services; +namespace SampleExtension.Radiologists.Web.FoundryLocal.Services; /// /// On-device chat completion provider backed by Microsoft.AI.Foundry.Local. diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/IFoundryLocalService.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Services/IFoundryLocalService.cs similarity index 91% rename from radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/IFoundryLocalService.cs rename to radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Services/IFoundryLocalService.cs index ebc7302..694364a 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Ai/Services/IFoundryLocalService.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Services/IFoundryLocalService.cs @@ -1,6 +1,6 @@ using OpenAI.Chat; -namespace SampleExtension.Radiologists.Web.Ai.Services; +namespace SampleExtension.Radiologists.Web.FoundryLocal.Services; /// /// Provides on-device chat completion via Microsoft.AI.Foundry.Local. diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Services/IQualityCheckService.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Services/IQualityCheckService.cs new file mode 100644 index 0000000..4b053d3 --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Services/IQualityCheckService.cs @@ -0,0 +1,13 @@ +using Dragon.Copilot.Radiologists.Models; + +namespace SampleExtension.Radiologists.Web.FoundryLocal.Services; + +/// +/// Abstraction for the component that turns an incoming radiology report +/// into a . In this sample it runs inference on +/// an on-device Foundry Local model. Replace with your own implementation. +/// +public interface IQualityCheckService +{ + Task ProcessAsync(ProcessRequest payload, CancellationToken cancellationToken = default); +} diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Services/QualityCheckService.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Services/QualityCheckService.cs new file mode 100644 index 0000000..5171729 --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/Services/QualityCheckService.cs @@ -0,0 +1,211 @@ +using System.Text.Json; +using Dragon.Copilot.Radiologists.Models; +using Microsoft.Extensions.Options; +using OpenAI.Chat; +using SampleExtension.Radiologists.Web.FoundryLocal.Configuration; + +namespace SampleExtension.Radiologists.Web.FoundryLocal.Services; + +/// +/// Quality-check service backed by an on-device Foundry Local model. +/// Runs inference locally via . +/// +public sealed class QualityCheckService : IQualityCheckService +{ + /// + /// System prompt used to instruct the on-device Foundry Local model. + /// + private const string SystemPrompt = """ + + You are a highly accurate medical transcription and coding assistant for radiology. You review radiology reports produced by speech-to-text software and surface two kinds of recommendations: + + 1. Clinical issues - transcription errors, medical inaccuracies, or ambiguous wording that could affect patient care. Illustrative (non-anchoring) examples: + - Misheard or phonetically similar words (e.g., "paddock steatosis" vs. "hepatic steatosis") + - Incorrect numbers or dates (e.g., "for views" vs. "4 views", "Nine 20 5/24" vs. "9/25/24") + - Findings inconsistent with the patient's age or biological sex (e.g., prostate findings on a Female patient, pediatric only findings on an adult) + + 2. Billing issues - documentation gaps that affect accurate charge capture or CPT code selection. Illustrative examples: + - Missing or ambiguous laterality (left vs. right) on a procedure + - Missing contrast indication when the study title implies contrast use + - Missing view count on a radiograph (affects CPT selection, e.g., 71045-71048 for chest X-ray) + - Procedure performed but not clearly documented in the impression + + Input format (JSON): + { + "report": {"reportText": ""}, + "patientInformation": { + "dateOfBirth": "", + "biologicalSex": "" + } + } + + Output format (JSON) respond with a single JSON object matching this schema exactly: + { + "qualityCheckResult": { + "recommendations": [ + { + "qualityCheckType": "Clinical" | "Billing", + "description": "", + "reason": "", + "severityScorePercent": , + "provenance": [ + { + "text": "", + "startPosition": , + "endPosition": + } + ] + } + ] + } + } + + Field semantics: + - provenance.text MUST be an exact substring of reportText (same characters, same casing); provenance MUST contain at least one entry pointing to that span. + - startPosition is the 0-based character index of provenance.text in reportText; endPosition is end-exclusive, so reportText.substring(startPosition, endPosition) == provenance.text and endPosition - startPosition == text.Length. + - severityScorePercent rubric: 0-24 trivial / stylistic, 25-49 minor (no clinical or billing impact), 50-74 moderate (affects clarity or CPT code selection), 75-100 critical (affects patient care or correct charge capture). + + Quality rules: + - Only flag issues clearly supported by reportText and patientInformation; do not invent findings, codes, measurements, or terminology not present in the input. If uncertain, omit the recommendation. + - Do not duplicate recommendations; merge overlapping issues into a single entry. + + Response rules: + - Respond with a single JSON object. No prose, no commentary, no Markdown code fences. + - The top-level object MUST have exactly one key: "qualityCheckResult". + - If no issues are found, or if the input is not valid JSON in the expected shape, return exactly: {"qualityCheckResult": {"recommendations": []}} + """; + + private static readonly JsonSerializerOptions DeserializeOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly ILogger _logger; + private readonly IFoundryLocalService _foundryLocal; + private readonly FoundryLocalSettings _foundryLocalSettings; + + public QualityCheckService( + ILogger logger, + IFoundryLocalService foundryLocal, + IOptions foundryLocalOptions) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(foundryLocal); + ArgumentNullException.ThrowIfNull(foundryLocalOptions); + + _logger = logger; + _foundryLocal = foundryLocal; + _foundryLocalSettings = foundryLocalOptions.Value; + } + + /// + public async Task ProcessAsync(ProcessRequest payload, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(payload); + + _logger.LogInformation( + "Running quality check on radiology request. CorrelationId={CorrelationId}, ReportLength={ReportLength}", + payload.SessionData.CorrelationId, + payload.Report?.ReportText.Length); + + _logger.LogInformation( + "Using Foundry Local provider (model={Model}). If this is the first request after startup, the model may need to download and load — this can take several minutes.", + _foundryLocalSettings.ModelAlias); + // GetChatClientAsync is lazily memoized; first call may be slow due to model download/load, + // but awaiting it (rather than blocking) keeps the ASP.NET Core thread-pool free. + var chatClient = await _foundryLocal.GetChatClientAsync(cancellationToken).ConfigureAwait(false); + return await ProcessWithChatClientAsync(payload, chatClient, cancellationToken).ConfigureAwait(false); + } + + private async Task ProcessWithChatClientAsync(ProcessRequest payload, ChatClient chatClient, CancellationToken cancellationToken) + { + var prompt = JsonSerializer.Serialize(new + { + report = new { reportText = payload.Report?.ReportText }, + patientInformation = new + { + dateOfBirth = payload.PatientInformation?.DateOfBirth, + biologicalSex = payload.PatientInformation?.BiologicalSex.ToString() + } + }); + + var json = await RunChatCompletionAsync(chatClient, prompt, cancellationToken).ConfigureAwait(false); + return MapToResult(json); + } + + internal static async Task RunChatCompletionAsync(ChatClient chatClient, string userMessage, CancellationToken cancellationToken) + { + List messages = new List() + { + new SystemChatMessage(SystemPrompt), + new UserChatMessage(userMessage), + }; + + var response = await chatClient.CompleteChatAsync(messages, new ChatCompletionOptions(), cancellationToken).ConfigureAwait(false); + return response.Value.Content[0].Text; + } + + private ProcessResponse MapToResult(string json) + { + const string qualityCheckResultPropertyName = "qualityCheckResult"; + + _logger.LogDebug("Raw JSON response from the agent: {Json}", json); + + // Strip a surrounding Markdown code fence (e.g. ```json ... ```) that some chat models emit. + json = json.Trim(); + if (json.StartsWith("```", StringComparison.Ordinal)) + { + // Drop the opening fence line (handles ``` and ```json). + var firstNewline = json.IndexOf('\n', StringComparison.Ordinal); + json = firstNewline >= 0 ? json[(firstNewline + 1)..] : json[3..]; + if (json.EndsWith("```", StringComparison.Ordinal)) + { + json = json[..^3]; + } + + json = json.Trim(); + } + + // Models can return malformed JSON, the wrong shape, or omit expected properties. + // Catch parse/deserialize failures so the extension always returns a well-formed + // ProcessResponse instead of bubbling a 500 to the caller. Partners adapting this + // sample can replace the fallback with their own error-handling strategy. + QualityCheckResult? qualityCheckResult = null; + try + { + using var root = JsonDocument.Parse(json); + if (root.RootElement.ValueKind == JsonValueKind.Object + && root.RootElement.TryGetProperty(qualityCheckResultPropertyName, out var qualityCheckResultElement)) + { + qualityCheckResult = qualityCheckResultElement.Deserialize(DeserializeOptions); + } + else + { + _logger.LogWarning( + "Model response did not contain expected '{Property}' property. Raw response: {Json}", + qualityCheckResultPropertyName, + json); + } + } + catch (JsonException ex) + { + _logger.LogWarning( + ex, + "Failed to parse model response as JSON. Raw response: {Json}", + json); + } + + var parsed = qualityCheckResult is not null; + return new ProcessResponse + { + Success = parsed, + Message = parsed + ? "Payload processed successfully." + : "Model returned malformed output; returning empty recommendations.", + Payload = new Dictionary + { + [qualityCheckResultPropertyName] = qualityCheckResult ?? new QualityCheckResult(), + }, + }; + } +} diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/appsettings.Development.json b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/appsettings.Development.json new file mode 100644 index 0000000..97b5669 --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information", + "Microsoft.AspNetCore.Authentication": "Information", + "SampleExtension.Radiologists": "Debug" + } + } +} diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/appsettings.json b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/appsettings.json new file mode 100644 index 0000000..ddf8fc0 --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/appsettings.json @@ -0,0 +1,27 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Authentication": { + "Enabled": false, + "TenantId": "", // Represents extension vendor's tenant id + "ClientId": "", // Represents extension vendor's client id + "Instance": "https://login.microsoftonline.com/", + "MapInboundClaims": false, + "AllowWebApiToBeAuthorizedByACL": true, + "RequiredClaims": { + "idtyp": [ "app" ], + "azp": [ "" ] // This represents Microsoft Dragon Copilot Extensions Runtime Application + } + }, + "FoundryLocal": { + "ModelAlias": "qwen2.5-1.5b", // Alternatives: qwen2.5-0.5b, phi-3.5-mini, phi-4-mini, mistral-7b, gpt-oss-20b. + "DeviceType": "CPU", // CPU works on machines without GPU/NPU. Other values: GPU, NPU + "AppName": "DragonCopilotRadiologistsSample", + "AppDataDir": "" // Model cache + logs location. Empty = %USERPROFILE%\.foundry (shared). Set absolute path to override. + } +} diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/nuget.config b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/nuget.config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.FoundryLocal/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Configuration/AuthenticationOptions.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Configuration/AuthenticationOptions.cs index 02ba21e..59f67f3 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Configuration/AuthenticationOptions.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Configuration/AuthenticationOptions.cs @@ -1,6 +1,3 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - namespace SampleExtension.Radiologists.Web.Quickstart.Configuration; /// diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Dockerfile b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Dockerfile new file mode 100644 index 0000000..60f169d --- /dev/null +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Dockerfile @@ -0,0 +1,21 @@ +# Radiologists Quickstart sample (cross-platform, mock data — no model or credentials needed). +# The build context is the repository root, e.g.: +# docker build -f radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Dockerfile -t radiologists-quickstart . +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy the whole repository so Central Package Management (Directory.Packages.props) +# and Directory.Build.props resolve during restore. +COPY . . + +RUN dotnet publish "radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/SampleExtension.Radiologists.Web.Quickstart.csproj" \ + -c Release -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "SampleExtension.Radiologists.Web.Quickstart.dll"] diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Extensions/ServiceCollectionExtensions.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Extensions/ServiceCollectionExtensions.cs index 52768f2..049be8d 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Extensions/ServiceCollectionExtensions.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,3 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - using System.Net.Http.Headers; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Identity.Web; diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Extensions/WebApplicationExtensions.cs b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Extensions/WebApplicationExtensions.cs index e802e14..7185f11 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Extensions/WebApplicationExtensions.cs +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.Quickstart/Extensions/WebApplicationExtensions.cs @@ -1,6 +1,3 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - namespace SampleExtension.Radiologists.Web.Quickstart.Extensions; /// diff --git a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.slnx b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.slnx index bacacb2..bd5d9c1 100644 --- a/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.slnx +++ b/radiologists/src/samples/Workflow/SampleExtension.Radiologists.Web.slnx @@ -2,4 +2,5 @@ +