diff --git a/hapi-fhir-jpaserver-empi/src/main/java/ca/uhn/fhir/jpa/empi/svc/EmpiMatchFinderSvcImpl.java b/hapi-fhir-jpaserver-empi/src/main/java/ca/uhn/fhir/jpa/empi/svc/EmpiMatchFinderSvcImpl.java index 66286163891..f8260b00469 100644 --- a/hapi-fhir-jpaserver-empi/src/main/java/ca/uhn/fhir/jpa/empi/svc/EmpiMatchFinderSvcImpl.java +++ b/hapi-fhir-jpaserver-empi/src/main/java/ca/uhn/fhir/jpa/empi/svc/EmpiMatchFinderSvcImpl.java @@ -35,6 +35,7 @@ import java.util.stream.Collectors; @Service public class EmpiMatchFinderSvcImpl implements IEmpiMatchFinderSvc { + @Autowired private EmpiCandidateSearchSvc myEmpiCandidateSearchSvc; @Autowired @@ -50,13 +51,4 @@ public class EmpiMatchFinderSvcImpl implements IEmpiMatchFinderSvc { .collect(Collectors.toList()); } - @Override - @Nonnull - public List findMatches(String theResourceType, IAnyResource theResource) { - List targetCandidates = getMatchedTargets(theResourceType, theResource); - return targetCandidates.stream() - .filter(candidate -> candidate.isMatch()) - .map(MatchedTarget::getTarget) - .collect(Collectors.toList()); - } } diff --git a/hapi-fhir-jpaserver-empi/src/test/java/ca/uhn/fhir/jpa/empi/provider/EmpiProviderMatchR4Test.java b/hapi-fhir-jpaserver-empi/src/test/java/ca/uhn/fhir/jpa/empi/provider/EmpiProviderMatchR4Test.java index d8f4dca618b..f7785341f1e 100644 --- a/hapi-fhir-jpaserver-empi/src/test/java/ca/uhn/fhir/jpa/empi/provider/EmpiProviderMatchR4Test.java +++ b/hapi-fhir-jpaserver-empi/src/test/java/ca/uhn/fhir/jpa/empi/provider/EmpiProviderMatchR4Test.java @@ -1,14 +1,27 @@ package ca.uhn.fhir.jpa.empi.provider; +import ca.uhn.fhir.empi.api.EmpiConstants; +import com.google.common.collect.Ordering; import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Enumerations; +import org.hl7.fhir.r4.model.Extension; import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.codesystems.MatchGrade; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import static org.junit.jupiter.api.Assertions.assertEquals; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; public class EmpiProviderMatchR4Test extends BaseProviderR4Test { + private static final Logger ourLog = LoggerFactory.getLogger(EmpiProviderMatchR4Test.class); + + public static final String NAME_GIVEN_JANET = NAME_GIVEN_JANE + "t"; @Override @BeforeEach @@ -26,7 +39,56 @@ public class EmpiProviderMatchR4Test extends BaseProviderR4Test { Bundle result = myEmpiProviderR4.match(newJane); assertEquals(1, result.getEntry().size()); - assertEquals(createdJane.getId(), result.getEntryFirstRep().getResource().getId()); + + Bundle.BundleEntryComponent entry0 = result.getEntry().get(0); + assertEquals(createdJane.getId(), entry0.getResource().getId()); + + Bundle.BundleEntrySearchComponent searchComponent = entry0.getSearch(); + assertEquals(Bundle.SearchEntryMode.MATCH, searchComponent.getMode()); + + assertEquals(2.0 / 3.0, searchComponent.getScore().doubleValue(), 0.01); + Extension matchGradeExtension = searchComponent.getExtensionByUrl(EmpiConstants.FIHR_STRUCTURE_DEF_MATCH_GRADE_URL_NAMESPACE); + assertNotNull(matchGradeExtension); + assertEquals(MatchGrade.CERTAIN.toCode(), matchGradeExtension.getValue().toString()); + } + + @Test + public void testMatchOrder() throws Exception { + Patient jane0 = buildJanePatient(); + Patient createdJane1 = createPatient(jane0); + + Patient jane1 = buildPatientWithNameAndId(NAME_GIVEN_JANET, JANE_ID); + jane1.setActive(true); + Patient createdJane2 = createPatient(jane1); + + Patient newJane = buildJanePatient(); + + Bundle result = myEmpiProviderR4.match(newJane); + assertEquals(2, result.getEntry().size()); + + Bundle.BundleEntryComponent entry0 = result.getEntry().get(0); + assertTrue(jane0.getId().equals(((Patient) entry0.getResource()).getId()), "First match should be Jane"); + Bundle.BundleEntryComponent entry1 = result.getEntry().get(1); + assertTrue(jane1.getId().equals(((Patient) entry1.getResource()).getId()), "Second match should be Janet"); + + List scores = result.getEntry() + .stream() + .map(bec -> bec.getSearch().getScore().doubleValue()) + .collect(Collectors.toList()); + assertTrue(Ordering.natural().reverse().isOrdered(scores), "Match scores must be descending"); + } + + @Test + public void testMismatch() throws Exception { + Patient jane = buildJanePatient(); + jane.setActive(true); + Patient createdJane = createPatient(jane); + + Patient paul = buildPaulPatient(); + paul.setActive(true); + + Bundle result = myEmpiProviderR4.match(paul); + assertEquals(0, result.getEntry().size()); } @Test diff --git a/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/api/EmpiConstants.java b/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/api/EmpiConstants.java index 4b3a134f124..1a1d53eabc0 100644 --- a/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/api/EmpiConstants.java +++ b/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/api/EmpiConstants.java @@ -32,5 +32,6 @@ public class EmpiConstants { public static final String HAPI_ENTERPRISE_IDENTIFIER_SYSTEM = "http://hapifhir.io/fhir/NamingSystem/empi-person-enterprise-id"; public static final String ALL_RESOURCE_SEARCH_PARAM_TYPE = "*"; + public static final String FIHR_STRUCTURE_DEF_MATCH_GRADE_URL_NAMESPACE = "http://hl7.org/fhir/StructureDefinition/match-grade"; } diff --git a/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/api/EmpiMatchOutcome.java b/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/api/EmpiMatchOutcome.java index 366504e2bcf..9f9c7348977 100644 --- a/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/api/EmpiMatchOutcome.java +++ b/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/api/EmpiMatchOutcome.java @@ -105,6 +105,26 @@ public final class EmpiMatchOutcome { return this; } + /** + * Gets normalized score that is in the range from zero to one + * + * @return + * Returns the normalized score + */ + public Double getNormalizedScore() { + if (vector == 0) { + return 0.0; + } else if (score > vector) { + return 1.0; + } + + double retVal = score / vector; + if (retVal < 0) { + retVal = 0.0; + } + return retVal; + } + @Override public String toString() { return new ToStringBuilder(this) diff --git a/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/api/IEmpiMatchFinderSvc.java b/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/api/IEmpiMatchFinderSvc.java index 9a9e46adf7f..3304b8e9a23 100644 --- a/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/api/IEmpiMatchFinderSvc.java +++ b/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/api/IEmpiMatchFinderSvc.java @@ -37,16 +37,4 @@ public interface IEmpiMatchFinderSvc { */ @Nonnull List getMatchedTargets(String theResourceType, IAnyResource theResource); - - /** - * Used by the $match operation. - * Retrieve a list of Patient/Practitioner matches, based on the given {@link IAnyResource} - * Internally, performs all EMPI matching rules on the type of the resource then returns only those - * with a match result of MATCHED. - * - * @param theResourceType the type of the resource. - * @param theResource the resource that we are attempting to find matches for. - * @return a List of {@link IAnyResource} representing all people who had a MATCH outcome. - */ - List findMatches(String theResourceType, IAnyResource theResource); } diff --git a/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/provider/EmpiProviderDstu3.java b/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/provider/EmpiProviderDstu3.java index 6c17e4517f8..5f85300d643 100644 --- a/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/provider/EmpiProviderDstu3.java +++ b/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/provider/EmpiProviderDstu3.java @@ -21,11 +21,13 @@ package ca.uhn.fhir.empi.provider; */ import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.empi.api.EmpiConstants; import ca.uhn.fhir.empi.api.EmpiLinkJson; import ca.uhn.fhir.empi.api.IEmpiControllerSvc; import ca.uhn.fhir.empi.api.IEmpiExpungeSvc; import ca.uhn.fhir.empi.api.IEmpiMatchFinderSvc; import ca.uhn.fhir.empi.api.IEmpiSubmitSvc; +import ca.uhn.fhir.empi.api.MatchedTarget; import ca.uhn.fhir.empi.model.EmpiTransactionContext; import ca.uhn.fhir.rest.annotation.IdParam; import ca.uhn.fhir.rest.annotation.Operation; @@ -37,6 +39,7 @@ import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; import ca.uhn.fhir.util.ParametersUtil; import org.apache.commons.lang3.StringUtils; import org.hl7.fhir.dstu3.model.Bundle; +import org.hl7.fhir.dstu3.model.CodeType; import org.hl7.fhir.dstu3.model.DecimalType; import org.hl7.fhir.dstu3.model.InstantType; import org.hl7.fhir.dstu3.model.Parameters; @@ -45,10 +48,13 @@ import org.hl7.fhir.dstu3.model.Person; import org.hl7.fhir.dstu3.model.Practitioner; import org.hl7.fhir.dstu3.model.Resource; import org.hl7.fhir.dstu3.model.StringType; +import org.hl7.fhir.dstu3.model.codesystems.MatchGrade; import org.hl7.fhir.instance.model.api.IAnyResource; import org.hl7.fhir.instance.model.api.IIdType; import java.util.Collection; +import java.util.Comparator; +import java.util.List; import java.util.UUID; import java.util.stream.Stream; @@ -73,24 +79,51 @@ public class EmpiProviderDstu3 extends BaseEmpiProvider { } @Operation(name = ProviderConstants.EMPI_MATCH, type = Patient.class) - public Bundle match(@OperationParam(name=ProviderConstants.EMPI_MATCH_RESOURCE, min = 1, max = 1) Patient thePatient) { + public Bundle match(@OperationParam(name = ProviderConstants.EMPI_MATCH_RESOURCE, min = 1, max = 1) Patient thePatient) { if (thePatient == null) { throw new InvalidRequestException("resource may not be null"); } - Collection matches = myEmpiMatchFinderSvc.findMatches("Patient", thePatient); + + List matches = myEmpiMatchFinderSvc.getMatchedTargets("Patient", thePatient); + matches.sort(Comparator.comparing((MatchedTarget m) -> m.getMatchResult().getNormalizedScore()).reversed()); Bundle retVal = new Bundle(); retVal.setType(Bundle.BundleType.SEARCHSET); retVal.setId(UUID.randomUUID().toString()); retVal.getMeta().setLastUpdatedElement(InstantType.now()); - for (IAnyResource next : matches) { - retVal.addEntry().setResource((Resource) next); + for (MatchedTarget next : matches) { + boolean shouldKeepThisEntry = next.isMatch() || next.isPossibleMatch(); + if (!shouldKeepThisEntry) { + continue; + } + + Bundle.BundleEntryComponent entry = new Bundle.BundleEntryComponent(); + entry.setResource((Resource) next.getTarget()); + entry.setSearch(toBundleEntrySearchComponent(next)); + + retVal.addEntry(entry); } return retVal; } + private Bundle.BundleEntrySearchComponent toBundleEntrySearchComponent(MatchedTarget theMatchedTarget) { + Bundle.BundleEntrySearchComponent searchComponent = new Bundle.BundleEntrySearchComponent(); + searchComponent.setMode(Bundle.SearchEntryMode.MATCH); + searchComponent.setScore(theMatchedTarget.getMatchResult().getNormalizedScore()); + + MatchGrade matchGrade = MatchGrade.PROBABLE; + if (theMatchedTarget.isMatch()) { + matchGrade = MatchGrade.CERTAIN; + } else if (theMatchedTarget.isPossibleMatch()) { + matchGrade = MatchGrade.POSSIBLE; + } + + searchComponent.addExtension(EmpiConstants.FIHR_STRUCTURE_DEF_MATCH_GRADE_URL_NAMESPACE, new CodeType(matchGrade.toCode())); + return searchComponent; + } + @Operation(name = ProviderConstants.EMPI_MERGE_PERSONS, type = Person.class) public Person mergePerson(@OperationParam(name=ProviderConstants.EMPI_MERGE_PERSONS_FROM_PERSON_ID, min = 1, max = 1) StringType theFromPersonId, @OperationParam(name=ProviderConstants.EMPI_MERGE_PERSONS_TO_PERSON_ID, min = 1, max = 1) StringType theToPersonId, diff --git a/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/provider/EmpiProviderR4.java b/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/provider/EmpiProviderR4.java index 8d0d3b9291a..d0fc3c2a277 100644 --- a/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/provider/EmpiProviderR4.java +++ b/hapi-fhir-server-empi/src/main/java/ca/uhn/fhir/empi/provider/EmpiProviderR4.java @@ -21,11 +21,13 @@ package ca.uhn.fhir.empi.provider; */ import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.empi.api.EmpiConstants; import ca.uhn.fhir.empi.api.EmpiLinkJson; import ca.uhn.fhir.empi.api.IEmpiControllerSvc; import ca.uhn.fhir.empi.api.IEmpiExpungeSvc; import ca.uhn.fhir.empi.api.IEmpiMatchFinderSvc; import ca.uhn.fhir.empi.api.IEmpiSubmitSvc; +import ca.uhn.fhir.empi.api.MatchedTarget; import ca.uhn.fhir.empi.model.EmpiTransactionContext; import ca.uhn.fhir.rest.annotation.IdParam; import ca.uhn.fhir.rest.annotation.Operation; @@ -39,7 +41,9 @@ import org.apache.commons.lang3.StringUtils; import org.hl7.fhir.instance.model.api.IAnyResource; import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.CodeType; import org.hl7.fhir.r4.model.DecimalType; +import org.hl7.fhir.r4.model.Extension; import org.hl7.fhir.r4.model.InstantType; import org.hl7.fhir.r4.model.IntegerType; import org.hl7.fhir.r4.model.Parameters; @@ -48,8 +52,11 @@ import org.hl7.fhir.r4.model.Person; import org.hl7.fhir.r4.model.Practitioner; import org.hl7.fhir.r4.model.Resource; import org.hl7.fhir.r4.model.StringType; +import org.hl7.fhir.r4.model.codesystems.MatchGrade; import java.util.Collection; +import java.util.Comparator; +import java.util.List; import java.util.UUID; import java.util.stream.Stream; @@ -74,25 +81,51 @@ public class EmpiProviderR4 extends BaseEmpiProvider { } @Operation(name = ProviderConstants.EMPI_MATCH, type = Patient.class) - public Bundle match(@OperationParam(name=ProviderConstants.EMPI_MATCH_RESOURCE, min = 1, max = 1) Patient thePatient) { + public Bundle match(@OperationParam(name = ProviderConstants.EMPI_MATCH_RESOURCE, min = 1, max = 1) Patient thePatient) { if (thePatient == null) { throw new InvalidRequestException("resource may not be null"); } - Collection matches = myEmpiMatchFinderSvc.findMatches("Patient", thePatient); + List matches = myEmpiMatchFinderSvc.getMatchedTargets("Patient", thePatient); + matches.sort(Comparator.comparing((MatchedTarget m) -> m.getMatchResult().getNormalizedScore()).reversed()); Bundle retVal = new Bundle(); retVal.setType(Bundle.BundleType.SEARCHSET); retVal.setId(UUID.randomUUID().toString()); retVal.getMeta().setLastUpdatedElement(InstantType.now()); - for (IAnyResource next : matches) { - retVal.addEntry().setResource((Resource) next); + for (MatchedTarget next : matches) { + boolean shouldKeepThisEntry = next.isMatch() || next.isPossibleMatch(); + if (!shouldKeepThisEntry) { + continue; + } + + Bundle.BundleEntryComponent entry = new Bundle.BundleEntryComponent(); + entry.setResource((Resource) next.getTarget()); + entry.setSearch(toBundleEntrySearchComponent(next)); + + retVal.addEntry(entry); } return retVal; } + private Bundle.BundleEntrySearchComponent toBundleEntrySearchComponent(MatchedTarget theMatchedTarget) { + Bundle.BundleEntrySearchComponent searchComponent = new Bundle.BundleEntrySearchComponent(); + searchComponent.setMode(Bundle.SearchEntryMode.MATCH); + searchComponent.setScore(theMatchedTarget.getMatchResult().getNormalizedScore()); + + MatchGrade matchGrade = MatchGrade.PROBABLE; + if (theMatchedTarget.isMatch()) { + matchGrade = MatchGrade.CERTAIN; + } else if (theMatchedTarget.isPossibleMatch()) { + matchGrade = MatchGrade.POSSIBLE; + } + + searchComponent.addExtension(EmpiConstants.FIHR_STRUCTURE_DEF_MATCH_GRADE_URL_NAMESPACE, new CodeType(matchGrade.toCode())); + return searchComponent; + } + @Operation(name = ProviderConstants.EMPI_MERGE_PERSONS, type = Person.class) public Person mergePersons(@OperationParam(name=ProviderConstants.EMPI_MERGE_PERSONS_FROM_PERSON_ID, min = 1, max = 1) StringType theFromPersonId, @OperationParam(name=ProviderConstants.EMPI_MERGE_PERSONS_TO_PERSON_ID, min = 1, max = 1) StringType theToPersonId, diff --git a/hapi-fhir-server-empi/src/test/java/ca/uhn/fhir/empi/api/EmpiMatchOutcomeTest.java b/hapi-fhir-server-empi/src/test/java/ca/uhn/fhir/empi/api/EmpiMatchOutcomeTest.java new file mode 100644 index 00000000000..ecd5d8f52f5 --- /dev/null +++ b/hapi-fhir-server-empi/src/test/java/ca/uhn/fhir/empi/api/EmpiMatchOutcomeTest.java @@ -0,0 +1,27 @@ +package ca.uhn.fhir.empi.api; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class EmpiMatchOutcomeTest { + + @Test + void testNormalizedScore() { + EmpiMatchOutcome outcome = new EmpiMatchOutcome(0l, 0.0); + assertEquals(0.0, outcome.getNormalizedScore()); + + outcome = new EmpiMatchOutcome(10l, 10.0); + assertEquals(1.0, outcome.getNormalizedScore()); + + outcome = new EmpiMatchOutcome(10l, -10.0); + assertEquals(0.0, outcome.getNormalizedScore()); + + outcome = new EmpiMatchOutcome(3l, 2.0); + assertEquals(2.0 / 3.0, outcome.getNormalizedScore(), 0.0001); + + outcome = new EmpiMatchOutcome(5l, 19.0); + assertEquals(1.0, outcome.getNormalizedScore()); + } + +}