diff --git a/Azure_Pipelines/azure-pipelines.measureeval.cd.yaml b/Azure_Pipelines/azure-pipelines.measureeval.cd.yaml index 73ec8bdcf..d66583bc4 100644 --- a/Azure_Pipelines/azure-pipelines.measureeval.cd.yaml +++ b/Azure_Pipelines/azure-pipelines.measureeval.cd.yaml @@ -73,7 +73,7 @@ steps: Write-Host "Set MyTag to: $myTag1" - task: Docker@2 - displayName: "Build & Push Audit Docker Image" + displayName: "Build & Push MeasureEval Docker Image" condition: always() inputs: containerRegistry: $(containerRegistry) @@ -89,3 +89,16 @@ steps: inputs: targetPath: '$(Build.ArtifactStagingDirectory)' artifact: 'manifest' + + - task: Maven@2 + displayName: Package CLI + inputs: + mavenPomFile: 'Java/pom.xml' + goals: 'clean package' + options: '-P cli -pl measureeval -am' + + - task: PublishPipelineArtifact@1 + displayName: 'Publish MeasureEval CLI Jar' + inputs: + targetPath: 'Java/measureeval/target/measureeval-cli.jar' + artifact: 'measureeval-cli-jar' \ No newline at end of file diff --git a/Java/measureeval/pom.xml b/Java/measureeval/pom.xml index eda847413..ed0b98ca3 100644 --- a/Java/measureeval/pom.xml +++ b/Java/measureeval/pom.xml @@ -190,7 +190,6 @@ cli com.lantanagroup.link.measureeval.FileSystemInvocation - src/main/resources/logback-cli.xml measureeval-cli diff --git a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/FileSystemInvocation.java b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/FileSystemInvocation.java index 6499cc275..5a8661135 100644 --- a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/FileSystemInvocation.java +++ b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/FileSystemInvocation.java @@ -1,7 +1,12 @@ package com.lantanagroup.link.measureeval; import ca.uhn.fhir.context.FhirContext; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.core.joran.spi.JoranException; import com.lantanagroup.link.measureeval.services.MeasureEvaluator; +import com.lantanagroup.link.measureeval.utils.CqlLogAppender; +import com.lantanagroup.link.measureeval.utils.CqlUtils; import com.lantanagroup.link.measureeval.utils.StreamUtils; import org.apache.commons.io.FileUtils; import org.apache.commons.io.FilenameUtils; @@ -28,6 +33,15 @@ public class FileSystemInvocation { private static final FhirContext fhirContext = FhirContext.forR4Cached(); private static final Logger logger = LoggerFactory.getLogger(FileSystemInvocation.class); + private static void configureLogging(Bundle bundle) throws JoranException { + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + context.reset(); + JoranConfigurator configurator = new JoranConfigurator(); + configurator.setContext(context); + configurator.doConfigure(ClassLoader.getSystemResource("logback-cli.xml")); + CqlLogAppender.start(context, libraryId -> CqlUtils.getLibrary(bundle, libraryId)); + } + private static Bundle getBundle(String measureBundlePath) throws IOException { logger.info("Loading measure bundle from: {}", measureBundlePath); @@ -138,7 +152,7 @@ private static Patient findPatient(Bundle bundle) { .orElseThrow(() -> new IllegalArgumentException("Patient resource not found in bundle")); } - private static void evaluatePatientBundle(String patientBundlePath, Bundle patientBundle, String start, String end, MeasureEvaluator evaluator) { + private static void evaluatePatientBundle(Bundle patientBundle, String start, String end, MeasureEvaluator evaluator, boolean isDebug) { Patient patient = findPatient(patientBundle); var report = evaluator.evaluate( new DateTimeType(start), @@ -162,6 +176,7 @@ public static void main(String[] args) { try { Bundle measureBundle = getBundle(measureBundlePath); + configureLogging(measureBundle); MeasureEvaluator evaluator = MeasureEvaluator.compile(fhirContext, measureBundle, true); File patientBundleFile = new File(patientBundlePath); @@ -171,11 +186,11 @@ public static void main(String[] args) { for (Bundle patientBundle : patientBundles) { logger.info("\n==================================================="); - evaluatePatientBundle(patientBundlePath, patientBundle, start, end, evaluator); + evaluatePatientBundle(patientBundle, start, end, evaluator, true); } } else { Bundle patientBundle = getBundle(patientBundlePath); - evaluatePatientBundle(patientBundlePath, patientBundle, start, end, evaluator); + evaluatePatientBundle(patientBundle, start, end, evaluator, true); } } catch (Exception e) { System.err.println("Error occurred while evaluating measure: " + e.getMessage()); diff --git a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/MeasureEvalApplication.java b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/MeasureEvalApplication.java index ed06ee75b..76e847cc3 100644 --- a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/MeasureEvalApplication.java +++ b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/MeasureEvalApplication.java @@ -1,9 +1,14 @@ package com.lantanagroup.link.measureeval; +import ch.qos.logback.classic.LoggerContext; +import com.lantanagroup.link.measureeval.services.MeasureEvaluatorCache; +import com.lantanagroup.link.measureeval.utils.CqlLogAppender; +import org.slf4j.LoggerFactory; import org.springframework.boot.Banner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.data.mongodb.config.EnableMongoAuditing; @@ -20,4 +25,10 @@ public static void main(String[] args) { application.setBannerMode(Banner.Mode.OFF); application.run(args); } + + @Bean + public CqlLogAppender cqlLogAppender(MeasureEvaluatorCache measureEvaluatorCache) { + LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + return CqlLogAppender.start(loggerContext, measureEvaluatorCache); + } } diff --git a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/configs/LinkConfig.java b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/configs/LinkConfig.java index b7e5fe78d..f0ec6343b 100644 --- a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/configs/LinkConfig.java +++ b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/configs/LinkConfig.java @@ -16,6 +16,8 @@ public class LinkConfig { private String reportabilityPredicate; + private boolean cqlDebug = false; + @Bean @SuppressWarnings("unchecked") public Predicate reportabilityPredicate() throws Exception { diff --git a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/controllers/MeasureDefinitionController.java b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/controllers/MeasureDefinitionController.java index 7e8248664..383f51549 100644 --- a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/controllers/MeasureDefinitionController.java +++ b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/controllers/MeasureDefinitionController.java @@ -1,5 +1,6 @@ package com.lantanagroup.link.measureeval.controllers; +import ca.uhn.fhir.context.FhirContext; import com.fasterxml.jackson.annotation.JsonView; import com.lantanagroup.link.measureeval.entities.MeasureDefinition; import com.lantanagroup.link.measureeval.repositories.MeasureDefinitionRepository; @@ -7,9 +8,12 @@ import com.lantanagroup.link.measureeval.services.MeasureDefinitionBundleValidator; import com.lantanagroup.link.measureeval.services.MeasureEvaluator; import com.lantanagroup.link.measureeval.services.MeasureEvaluatorCache; +import com.lantanagroup.link.measureeval.utils.CqlUtils; import com.lantanagroup.link.shared.auth.PrincipalUser; import io.opentelemetry.api.trace.Span; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import javassist.NotFoundException; import org.apache.commons.text.StringEscapeUtils; import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.MeasureReport; @@ -98,21 +102,50 @@ public MeasureDefinition put(@AuthenticationPrincipal PrincipalUser user, @PathV return entity; } + @GetMapping("/{id}/{library-id}/$cql") + @PreAuthorize("hasAuthority('IsLinkAdmin')") + @Operation(summary = "Get the CQL for a measure definition's library", tags = {"Measure Definitions"}) + @Parameter(name = "id", description = "The ID of the measure definition", required = true) + @Parameter(name = "library-id", description = "The ID of the library in the measure definition", required = true) + @Parameter(name = "range", description = "The range of the CQL to return (e.g. 37:1-38:22)", required = false) + public String getMeasureLibraryCQL( + @PathVariable("id") String measureId, + @PathVariable("library-id") String libraryId, + @RequestParam(value = "range", required = false) String range) { + + // Test that the range format is correct (i.e. "37:1-38:22") + if (range != null && !range.matches("\\d+:\\d+-\\d+:\\d+")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid range format"); + } + + // Get the measure definition from the repo by ID + MeasureDefinition measureDefinition = repository.findById(measureId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + + try { + return CqlUtils.getCql(measureDefinition.getBundle(), libraryId, range); + } catch (NotFoundException e) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage(), e); + } + } + @PostMapping("/{id}/$evaluate") @PreAuthorize("hasAuthority('IsLinkAdmin')") @Operation(summary = "Evaluate a measure against data in request body", tags = {"Measure Definitions"}) - public MeasureReport evaluate(@AuthenticationPrincipal PrincipalUser user, @PathVariable String id, @RequestBody Parameters parameters) { + @Parameter(name = "id", description = "The ID of the measure definition", required = true) + @Parameter(name = "parameters", description = "The parameters to use in the evaluation", required = true) + @Parameter(name = "debug", description = "Whether to log CQL debugging information during evaluation", required = false) + public MeasureReport evaluate(@AuthenticationPrincipal PrincipalUser user, @PathVariable String id, @RequestBody Parameters parameters, @RequestParam(required = false, defaultValue = "false") boolean debug) { if (user != null){ Span currentSpan = Span.current(); currentSpan.setAttribute("user", user.getEmailAddress()); } - MeasureEvaluator evaluator = evaluatorCache.get(id); - if (evaluator == null) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND); - } + try { - return evaluator.evaluate(parameters); + // Ensure that a measure evaluator is cached (so that CQL logging can use it) + MeasureEvaluator evaluator = evaluatorCache.get(id); + // But recompile the bundle every time because the debug flag may not match what's in the cache + return MeasureEvaluator.compileAndEvaluate(FhirContext.forR4(), evaluator.getBundle(), parameters, debug); } catch (Exception e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e); } diff --git a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/repositories/LinkInMemoryFhirRepository.java b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/repositories/LinkInMemoryFhirRepository.java new file mode 100644 index 000000000..a17ca3892 --- /dev/null +++ b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/repositories/LinkInMemoryFhirRepository.java @@ -0,0 +1,57 @@ +package com.lantanagroup.link.measureeval.repositories; + +import ca.uhn.fhir.context.FhirContext; +import org.hl7.fhir.instance.model.api.IBaseBundle; +import org.hl7.fhir.r4.model.Bundle; +import org.opencds.cqf.fhir.utility.repository.InMemoryFhirRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * This class extends the InMemoryFhirRepository to provide a transaction method that will update the resources in the repository. + * This implementation is primarily used to avoid the exception stack trace that is thrown when the InMemoryFhirRepository.transaction method is called by measure eval, but is not implemented + * in the default InMemoryFhirRepository. + */ +public class LinkInMemoryFhirRepository extends InMemoryFhirRepository { + private static final Logger logger = LoggerFactory.getLogger(LinkInMemoryFhirRepository.class); + + public LinkInMemoryFhirRepository(FhirContext context) { + super(context); + } + + public LinkInMemoryFhirRepository(FhirContext context, IBaseBundle bundle) { + super(context, bundle); + } + + @Override + public B transaction(B transaction, Map headers) { + if (!(transaction instanceof Bundle)) { + return transaction; + } + + Bundle bundle = (Bundle) transaction; + + if (bundle.getEntry() == null || bundle.getEntry().size() == 0) { + return transaction; + } + + for (Bundle.BundleEntryComponent entry : bundle.getEntry()) { + if (entry != null && entry.hasResource()) { + try { + // Ensure each resource has an ID, or create a GUID for them + if (!entry.getResource().hasId()) { + entry.getResource().setId(java.util.UUID.randomUUID().toString()); + } + + this.update(entry.getResource()); + } catch (Exception ex) { + logger.warn("Failed to process resource in transaction", ex); + } + } + } + + return transaction; + } +} diff --git a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/LibraryResolver.java b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/LibraryResolver.java new file mode 100644 index 000000000..04cdc5367 --- /dev/null +++ b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/LibraryResolver.java @@ -0,0 +1,7 @@ +package com.lantanagroup.link.measureeval.services; + +import org.hl7.fhir.r4.model.Library; + +public interface LibraryResolver { + Library resolve(String libraryId); +} diff --git a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/MeasureEvaluator.java b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/MeasureEvaluator.java index d5aa7a3a5..60293027c 100644 --- a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/MeasureEvaluator.java +++ b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/MeasureEvaluator.java @@ -3,8 +3,10 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.FhirVersionEnum; import ca.uhn.fhir.model.api.TemporalPrecisionEnum; +import com.lantanagroup.link.measureeval.repositories.LinkInMemoryFhirRepository; import com.lantanagroup.link.measureeval.utils.ParametersUtils; import com.lantanagroup.link.measureeval.utils.StreamUtils; +import lombok.Getter; import org.cqframework.cql.cql2elm.LibraryBuilder; import org.hl7.fhir.r4.model.*; import org.opencds.cqf.fhir.api.Repository; @@ -14,7 +16,6 @@ import org.opencds.cqf.fhir.cr.measure.MeasureEvaluationOptions; import org.opencds.cqf.fhir.cr.measure.r4.R4MeasureService; import org.opencds.cqf.fhir.utility.monad.Eithers; -import org.opencds.cqf.fhir.utility.repository.InMemoryFhirRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,6 +29,7 @@ public class MeasureEvaluator { private final FhirContext fhirContext; private final MeasureEvaluationOptions options; + @Getter private final Bundle bundle; private final Measure measure; @@ -74,10 +76,6 @@ private MeasureEvaluator(FhirContext fhirContext, Bundle bundle, boolean isDebug } } - public static MeasureEvaluator compile(FhirContext fhirContext, Bundle bundle) { - return compile(fhirContext, bundle, false); - } - public static MeasureEvaluator compile(FhirContext fhirContext, Bundle bundle, boolean isDebug) { MeasureEvaluator instance = new MeasureEvaluator(fhirContext, bundle, isDebug); instance.compile(); @@ -94,12 +92,21 @@ private void compile() { doEvaluate(null, null, new StringType(subject), additionalData); } + public static MeasureReport compileAndEvaluate(FhirContext fhirContext, Bundle bundle, Parameters parameters, boolean isDebug) { + MeasureEvaluator evaluator = new MeasureEvaluator(fhirContext, bundle, isDebug); + DateTimeType periodStart = ParametersUtils.getValue(parameters, "periodStart", DateTimeType.class); + DateTimeType periodEnd = ParametersUtils.getValue(parameters, "periodEnd", DateTimeType.class); + StringType subject = ParametersUtils.getValue(parameters, "subject", StringType.class); + Bundle additionalData = ParametersUtils.getResource(parameters, "additionalData", Bundle.class); + return evaluator.doEvaluate(periodStart, periodEnd, subject, additionalData); + } + private MeasureReport doEvaluate( DateTimeType periodStart, DateTimeType periodEnd, StringType subject, Bundle additionalData) { - Repository repository = new InMemoryFhirRepository(fhirContext, bundle); + Repository repository = new LinkInMemoryFhirRepository(fhirContext, bundle); R4MeasureService measureService = new R4MeasureService(repository, options); return measureService.evaluate( Eithers.forRight3(measure), @@ -117,16 +124,45 @@ private MeasureReport doEvaluate( null); } + public MeasureReport evaluate(Date periodStart, Date periodEnd, String patientId, Bundle additionalData) { + TimeZone utc = TimeZone.getTimeZone(ZoneOffset.UTC); + return evaluate( + new DateTimeType(periodStart, TemporalPrecisionEnum.MILLI, utc), + new DateTimeType(periodEnd, TemporalPrecisionEnum.MILLI, utc), + new StringType(new IdType(ResourceType.Patient.name(), patientId).getValue()), + additionalData); + } + + public MeasureReport evaluate(Parameters parameters) { + DateTimeType periodStart = ParametersUtils.getValue(parameters, "periodStart", DateTimeType.class); + DateTimeType periodEnd = ParametersUtils.getValue(parameters, "periodEnd", DateTimeType.class); + StringType subject = ParametersUtils.getValue(parameters, "subject", StringType.class); + Bundle additionalData = ParametersUtils.getResource(parameters, "additionalData", Bundle.class); + return evaluate(periodStart, periodEnd, subject, additionalData); + } + public MeasureReport evaluate( DateTimeType periodStart, DateTimeType periodEnd, StringType subject, Bundle additionalData) { List entries = additionalData.getEntry(); + logger.debug( "Evaluating measure: MEASURE=[{}] START=[{}] END=[{}] SUBJECT=[{}] RESOURCES=[{}]", measure.getUrl(), periodStart.asStringValue(), periodEnd.asStringValue(), subject, entries.size()); + + // Output debug/trace information about the results of the evaluation if (logger.isTraceEnabled()) { + // Output the group/population counts + for (MeasureReport.MeasureReportGroupComponent group : doEvaluate(periodStart, periodEnd, subject, additionalData).getGroup()) { + logger.trace("Group {}: {}", group.getId(), group.getPopulation().size()); + for (MeasureReport.MeasureReportGroupPopulationComponent population : group.getPopulation()) { + logger.trace("Population {}: {}", population.getCode().getCodingFirstRep().getDisplay(), population.getCount()); + } + } + + // Output each resource in the bundle for (int entryIndex = 0; entryIndex < entries.size(); entryIndex++) { Resource resource = entries.get(entryIndex).getResource(); logger.trace("Resource {}: {}/{}", entryIndex, resource.getResourceType(), resource.getIdPart()); @@ -134,21 +170,4 @@ public MeasureReport evaluate( } return doEvaluate(periodStart, periodEnd, subject, additionalData); } - - public MeasureReport evaluate(Date periodStart, Date periodEnd, String patientId, Bundle additionalData) { - TimeZone utc = TimeZone.getTimeZone(ZoneOffset.UTC); - return evaluate( - new DateTimeType(periodStart, TemporalPrecisionEnum.MILLI, utc), - new DateTimeType(periodEnd, TemporalPrecisionEnum.MILLI, utc), - new StringType(new IdType(ResourceType.Patient.name(), patientId).getValue()), - additionalData); - } - - public MeasureReport evaluate(Parameters parameters) { - DateTimeType periodStart = ParametersUtils.getValue(parameters, "periodStart", DateTimeType.class); - DateTimeType periodEnd = ParametersUtils.getValue(parameters, "periodEnd", DateTimeType.class); - StringType subject = ParametersUtils.getValue(parameters, "subject", StringType.class); - Bundle additionalData = ParametersUtils.getResource(parameters, "additionalData", Bundle.class); - return evaluate(periodStart, periodEnd, subject, additionalData); - } } diff --git a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/MeasureEvaluatorCache.java b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/MeasureEvaluatorCache.java index 84e833d48..a1f5d1fe6 100644 --- a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/MeasureEvaluatorCache.java +++ b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/MeasureEvaluatorCache.java @@ -1,22 +1,27 @@ package com.lantanagroup.link.measureeval.services; import ca.uhn.fhir.context.FhirContext; +import com.lantanagroup.link.measureeval.configs.LinkConfig; import com.lantanagroup.link.measureeval.entities.MeasureDefinition; import com.lantanagroup.link.measureeval.repositories.MeasureDefinitionRepository; +import com.lantanagroup.link.measureeval.utils.CqlUtils; +import org.hl7.fhir.r4.model.Library; import org.springframework.stereotype.Service; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Service -public class MeasureEvaluatorCache { +public class MeasureEvaluatorCache implements LibraryResolver { private final FhirContext fhirContext; private final MeasureDefinitionRepository definitionRepository; private final Map instancesById = new ConcurrentHashMap<>(); + private final LinkConfig linkConfig; - public MeasureEvaluatorCache(FhirContext fhirContext, MeasureDefinitionRepository definitionRepository) { + public MeasureEvaluatorCache(FhirContext fhirContext, MeasureDefinitionRepository definitionRepository, LinkConfig linkConfig) { this.fhirContext = fhirContext; this.definitionRepository = definitionRepository; + this.linkConfig = linkConfig; } public MeasureEvaluator get(String id) { @@ -25,11 +30,22 @@ public MeasureEvaluator get(String id) { if (measureDefinition == null) { return null; } - return MeasureEvaluator.compile(fhirContext, measureDefinition.getBundle()); + return MeasureEvaluator.compile(fhirContext, measureDefinition.getBundle(), this.linkConfig.isCqlDebug()); }); } public void remove(String id) { instancesById.remove(id); } + + @Override + public Library resolve(String libraryId) { + for (MeasureEvaluator instance : instancesById.values()) { + Library library = CqlUtils.getLibrary(instance.getBundle(), libraryId); + if (library != null) { + return library; + } + } + return null; + } } diff --git a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/utils/CqlLogAppender.java b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/utils/CqlLogAppender.java new file mode 100644 index 000000000..eee2c1f2d --- /dev/null +++ b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/utils/CqlLogAppender.java @@ -0,0 +1,117 @@ +package com.lantanagroup.link.measureeval.utils; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import com.lantanagroup.link.measureeval.services.LibraryResolver; +import javassist.NotFoundException; +import org.hl7.fhir.r4.model.Library; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class CqlLogAppender extends AppenderBase { + private static final Logger logger = LoggerFactory.getLogger(CqlLogAppender.class); + private static final Pattern LOG_PATTERN = Pattern.compile("([\\w.]+)\\.(\\d+:\\d+-\\d+:\\d+)\\(\\d+\\):\\s*(\\{\\}|[^\\s]+)"); + + private final LibraryResolver libraryResolver; + + public CqlLogAppender(LibraryResolver libraryResolver) { + this.libraryResolver = libraryResolver; + } + + public static CqlLogAppender start(LoggerContext context, LibraryResolver libraryResolver) { + CqlLogAppender appender = new CqlLogAppender(libraryResolver); + appender.setContext(context); + appender.start(); + ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("org.opencds.cqf.cql.engine.debug.DebugUtilities"); + logger.setLevel(Level.DEBUG); + logger.setAdditive(false); + logger.addAppender(appender); + return appender; + } + + @Override + protected void append(ILoggingEvent event) { + String message = event.getFormattedMessage(); + Matcher matcher = LOG_PATTERN.matcher(message); + + if (matcher.find()) { + String libraryId = matcher.group(1); + String range = matcher.group(2); + String output = matcher.group(3) + .replaceAll("org.hl7.fhir.r4.model.", "") + .replaceAll("@[0-9A-Fa-f]{6,8}", ""); + String cql = null; + + // Group the resources in the output + output = groupResources(output); + + Library library = libraryResolver.resolve(libraryId); + if (library == null) { + logger.warn("Failed to resolve library: {}", libraryId); + } else { + try { + cql = CqlUtils.getCql(library, range); + } catch (NotFoundException e) { + logger.warn("Failed to get CQL for libraryId={}, range={}, output={}", libraryId, range, output); + } + } + + // Custom processing with libraryId and range + processLogEntry(libraryId, range, output, cql); + } + } + + private String groupResources(String output) { + if (output == null || output.isEmpty() || output.equals("{}") || !output.startsWith("{")) { + return output; + } + + output = output.substring(1, output.length() - 1); + + if (output.endsWith(",")) { + output = output.substring(0, output.length() - 1); + } + + String[] resources = output.split(","); + Map resourceCount = new HashMap<>(); + + for (String resource : resources) { + resourceCount.put(resource, resourceCount.getOrDefault(resource, 0) + 1); + } + + StringBuilder groupedOutput = new StringBuilder(); + for (Map.Entry entry : resourceCount.entrySet()) { + if (groupedOutput.length() > 0) { + groupedOutput.append(","); + } + groupedOutput.append(entry.getKey()); + if (entry.getValue() > 1) { + groupedOutput.append("(").append(entry.getValue()).append(")"); + } + } + + return groupedOutput.toString(); + } + + private void processLogEntry(String libraryId, String range, String output, String cql) { + if (cql != null) { + Pattern definePattern = Pattern.compile("^define \"([^\"]+)\""); + Matcher matcher = definePattern.matcher(cql); + if (matcher.find()) { + String definition = matcher.group(1); + logger.info("CQL DEBUG: libraryId={}, range={}, output={}, cql-definition={}", libraryId, range, output, definition); + } else { + logger.info("CQL DEBUG: libraryId={}, range={}, output={}, cql=\n{}", libraryId, range, output, cql); + } + } else { + logger.info("CQL DEBUG: libraryId={}, range={}, output={}", libraryId, range, output); + } + } +} \ No newline at end of file diff --git a/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/utils/CqlUtils.java b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/utils/CqlUtils.java new file mode 100644 index 000000000..2f8e49cc9 --- /dev/null +++ b/Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/utils/CqlUtils.java @@ -0,0 +1,84 @@ +package com.lantanagroup.link.measureeval.utils; + +import javassist.NotFoundException; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Library; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class CqlUtils { + public static Library getLibrary(Bundle bundle, String libraryId) { + return bundle.getEntry().stream() + .map(Bundle.BundleEntryComponent::getResource) + .filter(Library.class::isInstance) + .map(Library.class::cast) + .filter(l -> { + if (l.getUrl() == null) { + return false; + } + + return l.getUrl().endsWith("/" + libraryId); + }) + .findFirst() + .orElse(null); + } + + public static String getCql(Bundle bundle, String libraryId, String range) throws NotFoundException { + Library library = getLibrary(bundle, libraryId); + if (library == null) { + throw new NotFoundException("Library not found in measure definition bundle"); + } + return getCql(library, range); + } + + public static String getCql(Library library, String range) throws NotFoundException { + // Get CQL from library's "content" and base64 decode it + String cql = library.getContent().stream() + .filter(content -> content.hasContentType() && content.getContentType().equals("text/cql")) + .findFirst() + .map(content -> new String(content.getData())) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "CQL content not found in library")); + + // Find range in CQL + if (range != null) { + // Split range into start and end line/column + return CqlUtils.getCqlRange(range, cql); + } + + return cql; + } + + private static String getCqlRange(String range, String cql) { + String[] rangeParts = range.split(":|-"); + + if (rangeParts.length != 4) { + return cql; + } + + int startLine = Integer.parseInt(rangeParts[0]); + int startColumn = Integer.parseInt(rangeParts[1]); + int endLine = Integer.parseInt(rangeParts[2]); + int endColumn = Integer.parseInt(rangeParts[3]); + + // Get the lines from the CQL + String[] lines = cql.split("\n"); + + // Get the lines in the range + StringBuilder rangeCql = new StringBuilder(); + for (int i = startLine - 1; i < endLine; i++) { + + if (i == startLine - 1) { + rangeCql.append(lines[i].substring(startColumn - 1)); + } else if (i == endLine - 1) { + rangeCql.append(lines[i].substring(0, endColumn)); + } else { + rangeCql.append(lines[i]); + } + if (i != endLine - 1) { + rangeCql.append("\n"); + } + } + + return rangeCql.toString(); + } +} diff --git a/Java/measureeval/src/main/resources/application.yml b/Java/measureeval/src/main/resources/application.yml index 2b67e2a20..fdd098a1f 100644 --- a/Java/measureeval/src/main/resources/application.yml +++ b/Java/measureeval/src/main/resources/application.yml @@ -51,6 +51,7 @@ spring: link: reportability-predicate: com.lantanagroup.link.measureeval.reportability.IsInInitialPopulation + cql-debug: false secret-management: key-vault-uri: '' diff --git a/Java/measureeval/src/main/resources/logback-cli.xml b/Java/measureeval/src/main/resources/logback-cli.xml index 0af8429e6..717f2d3c5 100644 --- a/Java/measureeval/src/main/resources/logback-cli.xml +++ b/Java/measureeval/src/main/resources/logback-cli.xml @@ -8,15 +8,9 @@ - - - - - - - + - + diff --git a/Java/measureeval/src/main/resources/logback-spring.xml b/Java/measureeval/src/main/resources/logback-spring.xml index 6f52eedf7..75ef8a4ac 100644 --- a/Java/measureeval/src/main/resources/logback-spring.xml +++ b/Java/measureeval/src/main/resources/logback-spring.xml @@ -41,18 +41,18 @@ + + - - + - - + diff --git a/Java/measureeval/src/test/java/com/lantanagroup/link/measureeval/services/MeasureEvaluatorEvaluationTests.java b/Java/measureeval/src/test/java/com/lantanagroup/link/measureeval/services/MeasureEvaluatorEvaluationTests.java index 3d1235805..275f0feaa 100644 --- a/Java/measureeval/src/test/java/com/lantanagroup/link/measureeval/services/MeasureEvaluatorEvaluationTests.java +++ b/Java/measureeval/src/test/java/com/lantanagroup/link/measureeval/services/MeasureEvaluatorEvaluationTests.java @@ -45,7 +45,7 @@ class MeasureEvaluatorEvaluationTests { void simpleCohortMeasureTrueTest() { var measurePackage = KnowledgeArtifactBuilder.SimpleCohortMeasureTrue.bundle(); validateMeasurePackage(measurePackage); - var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage); + var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage, false); var report = evaluator.evaluate(new DateTimeType("2024-01-01"), new DateTimeType("2024-12-31"), new StringType("Patient/simple-patient"), PatientDataBuilder.simplePatientOnlyBundle()); @@ -66,7 +66,7 @@ void simpleCohortMeasureTrueTest() { void simpleCohortMeasureFalseTest() { var measurePackage = KnowledgeArtifactBuilder.SimpleCohortMeasureFalse.bundle(); validateMeasurePackage(measurePackage); - var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage); + var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage, false); var report = evaluator.evaluate(new DateTimeType("2024-01-01"), new DateTimeType("2024-12-31"), new StringType("Patient/simple-patient"), PatientDataBuilder.simplePatientOnlyBundle()); @@ -88,7 +88,7 @@ void simpleCohortMeasureFalseTest() { void cohortMeasureWithValueSetTrueTest() { var measurePackage = KnowledgeArtifactBuilder.CohortMeasureWithValueSetTrue.bundle(); validateMeasurePackage(measurePackage); - var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage); + var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage, false); var report = evaluator.evaluate(new DateTimeType("2024-01-01"), new DateTimeType("2024-12-31"), new StringType("Patient/simple-patient"), PatientDataBuilder.simplePatientAndEncounterBundle()); @@ -117,7 +117,7 @@ void cohortMeasureWithValueSetTrueTest() { void cohortMeasureWithValueSetFalseTest() { var measurePackage = KnowledgeArtifactBuilder.CohortMeasureWithValueSetFalse.bundle(); validateMeasurePackage(measurePackage); - var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage); + var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage, false); var report = evaluator.evaluate(new DateTimeType("2024-01-01"), new DateTimeType("2024-12-31"), new StringType("Patient/simple-patient"), PatientDataBuilder.simplePatientAndEncounterBundle()); @@ -141,7 +141,7 @@ void cohortMeasureWithValueSetFalseTest() { void cohortMeasureWithSDETest() { var measurePackage = KnowledgeArtifactBuilder.CohortMeasureWithSDE.bundle(); validateMeasurePackage(measurePackage); - var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage); + var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage, false); var report = evaluator.evaluate(new DateTimeType("2024-01-01"), new DateTimeType("2024-12-31"), new StringType("Patient/simple-patient"), PatientDataBuilder.simplePatientEncounterAndConditionBundle()); @@ -184,7 +184,7 @@ void cohortMeasureWithSDETest() { void simpleProportionMeasureAllTrueNoExclusionTest() { var measurePackage = KnowledgeArtifactBuilder.SimpleProportionMeasureAllTrueNoExclusion.bundle(); validateMeasurePackage(measurePackage); - var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage); + var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage, false); var report = evaluator.evaluate(new DateTimeType("2024-01-01"), new DateTimeType("2024-12-31"), new StringType("Patient/simple-patient"), PatientDataBuilder.simplePatientOnlyBundle()); @@ -215,7 +215,7 @@ void simpleProportionMeasureAllTrueNoExclusionTest() { void simpleProportionMeasureAllFalseTest() { var measurePackage = KnowledgeArtifactBuilder.SimpleProportionMeasureAllFalse.bundle(); validateMeasurePackage(measurePackage); - var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage); + var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage, false); var report = evaluator.evaluate(new DateTimeType("2024-01-01"), new DateTimeType("2024-12-31"), new StringType("Patient/simple-patient"), PatientDataBuilder.simplePatientOnlyBundle()); @@ -246,7 +246,7 @@ void simpleProportionMeasureAllFalseTest() { void simpleRatioMeasureTest() { var measurePackage = KnowledgeArtifactBuilder.SimpleRatioMeasure.bundle(); validateMeasurePackage(measurePackage); - var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage); + var evaluator = MeasureEvaluator.compile(fhirContext, measurePackage, false); var report = evaluator.evaluate(new DateTimeType("2024-01-01"), new DateTimeType("2024-12-31"), new StringType("Patient/simple-patient"), PatientDataBuilder.simplePatientAndEncounterBundle()); @@ -281,7 +281,7 @@ void simpleRatioMeasureTest() { void simpleContinuousVariableMeasureTest() { var measurePackage = KnowledgeArtifactBuilder.SimpleContinuousVariableMeasure.bundle(); validateMeasurePackage(measurePackage); - var evaluator = MeasureEvaluator.compile(fhirContext, KnowledgeArtifactBuilder.SimpleContinuousVariableMeasure.bundle()); + var evaluator = MeasureEvaluator.compile(fhirContext, KnowledgeArtifactBuilder.SimpleContinuousVariableMeasure.bundle(), false); var report = evaluator.evaluate(new DateTimeType("2024-01-01"), new DateTimeType("2024-12-31"), new StringType("Patient/simple-patient"), PatientDataBuilder.simplePatientAndEncounterBundle()); diff --git a/Java/measureeval/src/test/java/com/lantanagroup/link/measureeval/services/MeasureEvaluatorInstantiationTests.java b/Java/measureeval/src/test/java/com/lantanagroup/link/measureeval/services/MeasureEvaluatorInstantiationTests.java index e2a29215c..067468eaa 100644 --- a/Java/measureeval/src/test/java/com/lantanagroup/link/measureeval/services/MeasureEvaluatorInstantiationTests.java +++ b/Java/measureeval/src/test/java/com/lantanagroup/link/measureeval/services/MeasureEvaluatorInstantiationTests.java @@ -29,7 +29,7 @@ void newInstanceWithR5FhirContextTest() { Bundle bundle = new Bundle(); bundle.addEntry().setResource(new Measure()); Assertions.assertThrows(IllegalArgumentException.class, - () -> MeasureEvaluator.compile(r5FhirContext, bundle), + () -> MeasureEvaluator.compile(r5FhirContext, bundle, false), "Unsupported FHIR version!"); } @@ -40,7 +40,7 @@ void newInstanceWithR5FhirContextTest() { void newInstanceEmptyBundleTest() { Bundle emptyBundle = new Bundle(); Assertions.assertThrows(IllegalArgumentException.class, - () -> MeasureEvaluator.compile(fhirContext, emptyBundle), + () -> MeasureEvaluator.compile(fhirContext, emptyBundle, false), "Please provide the necessary artifacts (e.g. Measure and Library resources) in the Bundle entry!"); } @@ -52,7 +52,7 @@ void newInstanceEmptyBundleTest() { void newInstanceMeasureWithoutPrimaryLibraryReference() { Bundle bundle = new Bundle(); bundle.addEntry().setResource(createMeasure(false)); - Assertions.assertThrows(IllegalArgumentException.class, () -> MeasureEvaluator.compile(fhirContext, bundle), + Assertions.assertThrows(IllegalArgumentException.class, () -> MeasureEvaluator.compile(fhirContext, bundle, false), "Measure null does not have a primary library specified"); } @@ -64,7 +64,7 @@ void newInstanceMeasureWithoutPrimaryLibraryReference() { void newInstanceMeasureWithMissingPrimaryLibraryReference() { Bundle bundle = new Bundle(); bundle.addEntry().setResource(createMeasure(true)); - Assertions.assertThrows(ResourceNotFoundException.class, () -> MeasureEvaluator.compile(fhirContext, bundle), + Assertions.assertThrows(ResourceNotFoundException.class, () -> MeasureEvaluator.compile(fhirContext, bundle, false), "Unable to find Library with url: https://example.com/Library/Nonexistent"); } @@ -77,7 +77,7 @@ void newInstanceMeasureWithPrimaryLibraryReferenceWithoutCqlContent() { Bundle bundle = new Bundle(); bundle.addEntry().setResource(createMeasure(true)); bundle.addEntry().setResource(createLibrary(false)); - Assertions.assertThrows(IllegalStateException.class, () -> MeasureEvaluator.compile(fhirContext, bundle), + Assertions.assertThrows(IllegalStateException.class, () -> MeasureEvaluator.compile(fhirContext, bundle, false), "Unable to load CQL/ELM for library: Nonexistent. Verify that the Library resource is available in your environment and has CQL/ELM content embedded."); } @@ -89,7 +89,7 @@ void newInstanceMeasureWithPrimaryLibraryReferenceWithCqlContent() { Bundle bundle = new Bundle(); bundle.addEntry().setResource(createMeasure(true)); bundle.addEntry().setResource(createLibrary(true)); - Assertions.assertDoesNotThrow(() -> MeasureEvaluator.compile(fhirContext, bundle)); + Assertions.assertDoesNotThrow(() -> MeasureEvaluator.compile(fhirContext, bundle, false)); } /** diff --git a/docker-compose.yml b/docker-compose.yml index dd22a123c..14facb73f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -190,7 +190,7 @@ services: - KAFKA_CFG_NODE_ID=1 - KAFKA_CFG_PROCESS_ROLES=broker,controller - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=1@127.0.0.1:9093 - - KAFKA_CFG_LISTENERS=SASL_PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094 + - KAFKA_CFG_LISTENERS=SASL_PLAINTEXT://127.0.0.1:9092,CONTROLLER://127.0.0.1:9093,EXTERNAL://127.0.0.1:9094 - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:SASL_PLAINTEXT,SASL_PLAINTEXT:SASL_PLAINTEXT,EXTERNAL:SASL_PLAINTEXT - KAFKA_CFG_ADVERTISED_LISTENERS=SASL_PLAINTEXT://127.0.0.1:9092,EXTERNAL://kafka_b:9094 - KAFKA_CLIENT_USERS=${KAFKA_SASL_CLIENT_USER} diff --git a/docs/README.md b/docs/README.md index 95ad0dd62..a3f801bfb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,4 +45,22 @@ When deployed, each service provides a Swagger UI for exploring its API. The Swa * JSON: `/swagger/v1/swagger.json` * Java Services * UI: `/swagger-ui.html` - * JSON: `/v3/api-docs` \ No newline at end of file + * JSON: `/v3/api-docs` + +## Java + +### Kafka Authentication + +If Kafka requires authentication (such as SASL_PLAINTEXT) the Java services use the following (example) properties: + +| Property Name | Value | +|-------------------------------------------|-----------------------------------------------------------------------------------------------------| +| spring.kafka.properties.sasl.jaas.config | org.apache.kafka.common.security.plain.PlainLoginModule required username=\"XXX\" password=\"XXX\"; | +| spring.kafka.properties.sasl.mechanism | PLAIN | +| spring.kafka.properties.security.protocol | SASL_PLAINTEXT | + +These properties can be applied when running/debugging the services locally by passing them as VM arguments, such as `-Dspring.kafka.properties.sasl.mechanism=PLAIN`. + +### Azure App Config + +Note: If a Java service is configured to use Azure App Config, keys in ACA take precedence over Java VM args _and_ Environment Variables. \ No newline at end of file diff --git a/docs/service_specs/measure_eval.md b/docs/service_specs/measure_eval.md index 7ea0caaaa..6335004f2 100644 --- a/docs/service_specs/measure_eval.md +++ b/docs/service_specs/measure_eval.md @@ -55,9 +55,10 @@ The **Measure Evaluation Service** provides REST endpoints to manage measure def - **PUT /api/measure-definition/{id}**: Creates or updates a measure definition with the specified ID. - **GET /api/measure-definition**: Retrieves a list of all measure definitions. -### Measure Evaluation +### Measure Evaluation & Testing -- **POST /api/measure-definition/{id}/$evaluate**: Evaluates a measure against clinical data provided in the request body. +- **POST /api/measure-definition/{id}/$evaluate**: Evaluates a measure against clinical data provided in the request body. May include a `debug` flag that indicates to create cql debug logs on the service during evaluation. +- **GET /api/measure-definition/{id}/{library-id}/$cql**: Retrieves the CQL for a specific measure definition and library. May include a `range` parameter that represents the range of CQL that is reported via debug logs. ### Health Check diff --git a/link-cloud.sln b/link-cloud.sln index e176c57aa..ee0c42eae 100644 --- a/link-cloud.sln +++ b/link-cloud.sln @@ -69,6 +69,47 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReportTests", "DotNet\Repor EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SubmissionTests", "DotNet\SubmissionTests\SubmissionTests.csproj", "{8EA05322-2C58-4781-A0C2-6F1FC58FD2FB}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzurePipelines", "AzurePipelines", "{D88CD83D-09AC-4632-8066-4D7C36817749}" + ProjectSection(SolutionItems) = preProject + Azure_Pipelines\azure-pipelines.account.cd.yaml = Azure_Pipelines\azure-pipelines.account.cd.yaml + Azure_Pipelines\azure-pipelines.account.ci.yaml = Azure_Pipelines\azure-pipelines.account.ci.yaml + Azure_Pipelines\azure-pipelines.audit.cd.yaml = Azure_Pipelines\azure-pipelines.audit.cd.yaml + Azure_Pipelines\azure-pipelines.audit.ci.yaml = Azure_Pipelines\azure-pipelines.audit.ci.yaml + Azure_Pipelines\azure-pipelines.bff.cd.yaml = Azure_Pipelines\azure-pipelines.bff.cd.yaml + Azure_Pipelines\azure-pipelines.bff.ci.yaml = Azure_Pipelines\azure-pipelines.bff.ci.yaml + Azure_Pipelines\azure-pipelines.census.cd.yaml = Azure_Pipelines\azure-pipelines.census.cd.yaml + Azure_Pipelines\azure-pipelines.census.ci.yaml = Azure_Pipelines\azure-pipelines.census.ci.yaml + Azure_Pipelines\azure-pipelines.dataacquisition.cd.yaml = Azure_Pipelines\azure-pipelines.dataacquisition.cd.yaml + Azure_Pipelines\azure-pipelines.dataacquisition.ci.yaml = Azure_Pipelines\azure-pipelines.dataacquisition.ci.yaml + Azure_Pipelines\azure-pipelines.db-schema-creation.yaml = Azure_Pipelines\azure-pipelines.db-schema-creation.yaml + Azure_Pipelines\azure-pipelines.demo.cd.yaml = Azure_Pipelines\azure-pipelines.demo.cd.yaml + Azure_Pipelines\azure-pipelines.demo.ci.yaml = Azure_Pipelines\azure-pipelines.demo.ci.yaml + Azure_Pipelines\azure-pipelines.gateway.cd.yaml = Azure_Pipelines\azure-pipelines.gateway.cd.yaml + Azure_Pipelines\azure-pipelines.gateway.ci.yaml = Azure_Pipelines\azure-pipelines.gateway.ci.yaml + Azure_Pipelines\azure-pipelines.measureeval.cd.yaml = Azure_Pipelines\azure-pipelines.measureeval.cd.yaml + Azure_Pipelines\azure-pipelines.measureeval.ci.yaml = Azure_Pipelines\azure-pipelines.measureeval.ci.yaml + Azure_Pipelines\azure-pipelines.normalization.cd.yaml = Azure_Pipelines\azure-pipelines.normalization.cd.yaml + Azure_Pipelines\azure-pipelines.normalization.ci.yaml = Azure_Pipelines\azure-pipelines.normalization.ci.yaml + Azure_Pipelines\azure-pipelines.notification.cd.yaml = Azure_Pipelines\azure-pipelines.notification.cd.yaml + Azure_Pipelines\azure-pipelines.notification.ci.yaml = Azure_Pipelines\azure-pipelines.notification.ci.yaml + Azure_Pipelines\azure-pipelines.patientlist.cd.yaml = Azure_Pipelines\azure-pipelines.patientlist.cd.yaml + Azure_Pipelines\azure-pipelines.patientstoquery.cd.yaml = Azure_Pipelines\azure-pipelines.patientstoquery.cd.yaml + Azure_Pipelines\azure-pipelines.querydispatch.cd.yaml = Azure_Pipelines\azure-pipelines.querydispatch.cd.yaml + Azure_Pipelines\azure-pipelines.querydispatch.ci.yaml = Azure_Pipelines\azure-pipelines.querydispatch.ci.yaml + Azure_Pipelines\azure-pipelines.report.cd.yaml = Azure_Pipelines\azure-pipelines.report.cd.yaml + Azure_Pipelines\azure-pipelines.report.ci.yaml = Azure_Pipelines\azure-pipelines.report.ci.yaml + Azure_Pipelines\azure-pipelines.shared.cd.yml = Azure_Pipelines\azure-pipelines.shared.cd.yml + Azure_Pipelines\azure-pipelines.submission.cd.yaml = Azure_Pipelines\azure-pipelines.submission.cd.yaml + Azure_Pipelines\azure-pipelines.submission.ci.yaml = Azure_Pipelines\azure-pipelines.submission.ci.yaml + Azure_Pipelines\azure-pipelines.tenant.cd.yaml = Azure_Pipelines\azure-pipelines.tenant.cd.yaml + Azure_Pipelines\azure-pipelines.tenant.ci.yaml = Azure_Pipelines\azure-pipelines.tenant.ci.yaml + Azure_Pipelines\azure-pipelines.validation.cd.yaml = Azure_Pipelines\azure-pipelines.validation.cd.yaml + Azure_Pipelines\azure-pipelines.validation.ci.yaml = Azure_Pipelines\azure-pipelines.validation.ci.yaml + Azure_Pipelines\azure-pipelines.web.cd.yaml = Azure_Pipelines\azure-pipelines.web.cd.yaml + Azure_Pipelines\deploy_all_services.yaml = Azure_Pipelines\deploy_all_services.yaml + Azure_Pipelines\deploy_tags_all.yml = Azure_Pipelines\deploy_tags_all.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU