This is an automated email from the ASF dual-hosted git repository. jkevan pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push: new 40130ee8d UNOMI-690, UNOMI-696: refactor control group (#531) 40130ee8d is described below commit 40130ee8d498ea07ffd1f8b40a1e38671f6197a1 Author: kevan Jahanshahi <jke...@apache.org> AuthorDate: Thu Nov 10 10:50:04 2022 +0100 UNOMI-690, UNOMI-696: refactor control group (#531) * UNOMI-690, UNOMI-696: refactor control group * UNOMI-690, UNOMI-696: Fix itests * UNOMI-690, UNOMI-696: Add new control group integration test and cover the feature with strong integration tests --- .../java/org/apache/unomi/api/ContextResponse.java | 40 +- .../apache/unomi/api/PersonalizationResult.java | 72 +- .../apache/unomi/api/PersonalizationStrategy.java | 4 +- .../main/java/org/apache/unomi/api/Profile.java | 2 +- .../main/java/org/apache/unomi/api/Session.java | 2 +- ...zationResult.java => SystemPropertiesItem.java} | 29 +- .../org/apache/unomi/itests/ContextServletIT.java | 338 ++++--- .../resources/personalization-control-group.json | 49 + .../resources/personalization-controlgroup.json | 988 --------------------- .../personalization-no-control-group.json | 45 + .../unomi/rest/endpoints/ContextJsonEndpoint.java | 4 +- .../impl/personalization/ControlGroup.java | 101 --- .../PersonalizationServiceImpl.java | 90 +- .../sorts/ControlGroupPersonalizationStrategy.java | 117 +++ .../sorts/FilterPersonalizationStrategy.java | 5 +- .../sorts/RandomPersonalizationStrategy.java | 8 +- .../sorts/ScorePersonalizationStrategy.java | 15 +- 17 files changed, 598 insertions(+), 1311 deletions(-) diff --git a/api/src/main/java/org/apache/unomi/api/ContextResponse.java b/api/src/main/java/org/apache/unomi/api/ContextResponse.java index b7467e018..2eaf098f3 100644 --- a/api/src/main/java/org/apache/unomi/api/ContextResponse.java +++ b/api/src/main/java/org/apache/unomi/api/ContextResponse.java @@ -20,11 +20,10 @@ package org.apache.unomi.api; import org.apache.unomi.api.conditions.Condition; import org.apache.unomi.api.services.RulesService; +import javax.xml.bind.annotation.XmlTransient; import java.io.Serializable; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; +import java.util.stream.Collectors; /** * A context server response resulting from the evaluation of a client's context request. Note that all returned values result of the evaluation of the data provided in the @@ -52,7 +51,7 @@ public class ContextResponse implements Serializable { private int processedEvents; - private Map<String, List<String>> personalizations; + private Map<String, PersonalizationResult> personalizationResults; private Set<Condition> trackedConditions; @@ -198,12 +197,39 @@ public class ContextResponse implements Serializable { this.processedEvents = processedEvents; } + /** + * @deprecated personalizations results are more complex since 2.1.0 and they are now available under: getPersonalizationResults() + */ + @Deprecated + @XmlTransient public Map<String, List<String>> getPersonalizations() { - return personalizations; + if (personalizationResults != null) { + return personalizationResults.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, + entry -> entry.getValue().getContentIds())); + } + return null; } + /** + * @deprecated personalizations results are more complex since 2.1.0 and they are now available under: setPersonalizationResults(Map<String, PersonalizationResult> personalizationResults) + */ + @Deprecated public void setPersonalizations(Map<String, List<String>> personalizations) { - this.personalizations = personalizations; + // do nothing, use setPersonalizationResults() instead + } + + /** + * Get the result of the personalization resolutions done during the context request. + * @return a Map key/value pair (key:personalization id, value:the result that contains the matching content ids and additional information) + */ + public Map<String, PersonalizationResult> getPersonalizationResults() { + return personalizationResults; + } + + public void setPersonalizationResults(Map<String, PersonalizationResult> personalizationResults) { + this.personalizationResults = personalizationResults; } /** diff --git a/api/src/main/java/org/apache/unomi/api/PersonalizationResult.java b/api/src/main/java/org/apache/unomi/api/PersonalizationResult.java index 446189a9a..15c6ab5a5 100644 --- a/api/src/main/java/org/apache/unomi/api/PersonalizationResult.java +++ b/api/src/main/java/org/apache/unomi/api/PersonalizationResult.java @@ -16,27 +16,89 @@ */ package org.apache.unomi.api; +import org.apache.unomi.api.services.EventService; + +import javax.xml.bind.annotation.XmlTransient; +import java.io.Serializable; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * A class to contain the result of a personalization, containing the list of content IDs as well as a changeType to - * indicate if a profile and/or a session was modified (to store control group information). + * indicate if a profile and/or a session was modified. */ -public class PersonalizationResult { +public class PersonalizationResult implements Serializable { + + public final static String ADDITIONAL_RESULT_INFO_IN_CONTROL_GROUP = "inControlGroup"; List<String> contentIds; - int changeType; - public PersonalizationResult(List<String> contentIds, int changeType) { + Map<String, Object> additionalResultInfos = new HashMap<>(); + + int changeType = EventService.NO_CHANGE; + + public PersonalizationResult() { + } + + public PersonalizationResult(List<String> contentIds) { this.contentIds = contentIds; - this.changeType = changeType; } + /** + * List of matching ids for current personalization + * @return the list of matching ids + */ public List<String> getContentIds() { return contentIds; } + public void setContentIds(List<String> contentIds) { + this.contentIds = contentIds; + } + + /** + * Useful open map to return additional result information to the client + * @return map of key/value pair for additional information, like: inControlGroup + */ + public Map<String, Object> getAdditionalResultInfos() { + return additionalResultInfos; + } + + public void setAdditionalResultInfos(Map<String, Object> additionalResultInfos) { + this.additionalResultInfos = additionalResultInfos; + } + + /** + * Is the current personalization result in a control group ? + * Control group are used to identify a profile or a session that should not get personalized results, + * instead the current profile/session should get a specific result (usually the same for all peoples falling in control group) + * Note: it's for now the responsibility of the client to decide what to do when the current personalization is under control group. + * + * @return true in case current profile or session is in control group for the personalization. + */ + @XmlTransient + public boolean isInControlGroup() { + return additionalResultInfos.containsKey(ADDITIONAL_RESULT_INFO_IN_CONTROL_GROUP) && + (Boolean) additionalResultInfos.get(ADDITIONAL_RESULT_INFO_IN_CONTROL_GROUP); + } + + public void setInControlGroup(boolean inControlGroup) { + this.additionalResultInfos.put(ADDITIONAL_RESULT_INFO_IN_CONTROL_GROUP, inControlGroup); + } + + /** + * Change code in case the personalization resolution modified the profile or the session + * Only used internally, and will not be serialized either for storage or response payload. + * + * @return change code + */ + @XmlTransient public int getChangeType() { return changeType; } + + public void addChanges(int changes) { + this.changeType |= changes; + } } diff --git a/api/src/main/java/org/apache/unomi/api/PersonalizationStrategy.java b/api/src/main/java/org/apache/unomi/api/PersonalizationStrategy.java index 152625e3f..5ceb941f7 100644 --- a/api/src/main/java/org/apache/unomi/api/PersonalizationStrategy.java +++ b/api/src/main/java/org/apache/unomi/api/PersonalizationStrategy.java @@ -32,7 +32,7 @@ public interface PersonalizationStrategy { * @param session the session to use for the personalization * @param personalizationRequest the request contains the contents to personalizes as well as the parameters for the * strategy (options) - * @return a list of content IDs resulting from the filtering/re-ordering + * @return the personalization result that contains the list of content IDs resulting from the filtering/re-ordering */ - List<String> personalizeList(Profile profile, Session session, PersonalizationService.PersonalizationRequest personalizationRequest); + PersonalizationResult personalizeList(Profile profile, Session session, PersonalizationService.PersonalizationRequest personalizationRequest); } diff --git a/api/src/main/java/org/apache/unomi/api/Profile.java b/api/src/main/java/org/apache/unomi/api/Profile.java index ea9583850..133fc7599 100644 --- a/api/src/main/java/org/apache/unomi/api/Profile.java +++ b/api/src/main/java/org/apache/unomi/api/Profile.java @@ -38,7 +38,7 @@ import java.util.*; * * @see Segment */ -public class Profile extends Item { +public class Profile extends Item implements SystemPropertiesItem { /** * The Profile ITEM_TYPE diff --git a/api/src/main/java/org/apache/unomi/api/Session.java b/api/src/main/java/org/apache/unomi/api/Session.java index 986323c12..8ca067720 100644 --- a/api/src/main/java/org/apache/unomi/api/Session.java +++ b/api/src/main/java/org/apache/unomi/api/Session.java @@ -27,7 +27,7 @@ import java.util.Map; * A time-bounded interaction between a user (via their associated {@link Profile}) and a unomi-enabled application. A session represents a sequence of operations the user * performed during its duration. In the context of web applications, sessions are usually linked to HTTP sessions. */ -public class Session extends Item implements TimestampedItem { +public class Session extends Item implements TimestampedItem, SystemPropertiesItem { /** * The Session ITEM_TYPE. diff --git a/api/src/main/java/org/apache/unomi/api/PersonalizationResult.java b/api/src/main/java/org/apache/unomi/api/SystemPropertiesItem.java similarity index 57% copy from api/src/main/java/org/apache/unomi/api/PersonalizationResult.java copy to api/src/main/java/org/apache/unomi/api/SystemPropertiesItem.java index 446189a9a..e191562cc 100644 --- a/api/src/main/java/org/apache/unomi/api/PersonalizationResult.java +++ b/api/src/main/java/org/apache/unomi/api/SystemPropertiesItem.java @@ -16,27 +16,18 @@ */ package org.apache.unomi.api; -import java.util.List; +import java.util.Map; /** - * A class to contain the result of a personalization, containing the list of content IDs as well as a changeType to - * indicate if a profile and/or a session was modified (to store control group information). + * An Item that is holding system properties. */ -public class PersonalizationResult { +public interface SystemPropertiesItem { - List<String> contentIds; - int changeType; - - public PersonalizationResult(List<String> contentIds, int changeType) { - this.contentIds = contentIds; - this.changeType = changeType; - } - - public List<String> getContentIds() { - return contentIds; - } - - public int getChangeType() { - return changeType; - } + /** + * Retrieves a Map of system property name - value pairs for this item. System properties can be used by implementations to store non-user visible properties needed for + * internal purposes. + * + * @return a Map of system property name - value pairs for this item + */ + Map<String, Object> getSystemProperties(); } diff --git a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java index cfa9275cd..c27a926d7 100644 --- a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java @@ -17,28 +17,14 @@ package org.apache.unomi.itests; -import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; -import org.apache.unomi.api.ContextRequest; -import org.apache.unomi.api.ContextResponse; -import org.apache.unomi.api.Event; -import org.apache.unomi.api.Metadata; -import org.apache.unomi.api.Profile; -import org.apache.unomi.api.Scope; -import org.apache.unomi.api.Session; +import org.apache.unomi.api.*; import org.apache.unomi.api.conditions.Condition; import org.apache.unomi.api.segments.Scoring; import org.apache.unomi.api.segments.Segment; -import org.apache.unomi.api.services.DefinitionsService; -import org.apache.unomi.api.services.EventService; -import org.apache.unomi.api.services.ProfileService; -import org.apache.unomi.api.services.ScopeService; -import org.apache.unomi.api.services.SegmentService; import org.apache.unomi.persistence.spi.CustomObjectMapper; -import org.apache.unomi.persistence.spi.PersistenceService; -import org.apache.unomi.schema.api.SchemaService; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -46,11 +32,8 @@ import org.junit.runner.RunWith; import org.ops4j.pax.exam.junit.PaxExam; import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; import org.ops4j.pax.exam.spi.reactors.PerSuite; -import org.ops4j.pax.exam.util.Filter; -import javax.inject.Inject; import java.io.File; -import java.io.IOException; import java.net.URI; import java.time.LocalDateTime; import java.time.ZoneId; @@ -70,7 +53,6 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThat; -import static org.junit.Assert.assertTrue; /** * Created by Ron Barabash on 5/4/2020. @@ -86,7 +68,9 @@ public class ContextServletIT extends BaseIT { private final static String TEST_EVENT_TYPE_SCHEMA = "schemas/events/test-event-type.json"; private final static String FLOAT_PROPERTY_EVENT_TYPE = "floatPropertyType"; private final static String FLOAT_PROPERTY_EVENT_TYPE_SCHEMA = "schemas/events/float-property-type.json"; + private final static String TEST_SESSION_ID = "dummy-session"; private final static String TEST_PROFILE_ID = "test-profile-id"; + private final static String TEST_PROFILE_FIRST_NAME = "contextServletIT_profile"; private final static String SEGMENT_ID = "test-segment-id"; private final static int SEGMENT_NUMBER_OF_DAYS = 30; @@ -113,6 +97,7 @@ public class ContextServletIT extends BaseIT { segmentService.setSegmentDefinition(segment); profile = new Profile(TEST_PROFILE_ID); + profile.setProperty("firstName", TEST_PROFILE_FIRST_NAME); profileService.save(profile); keepTrying("Profile " + TEST_PROFILE_ID + " not found in the required time", () -> profileService.load(TEST_PROFILE_ID), @@ -138,6 +123,7 @@ public class ContextServletIT extends BaseIT { TestUtils.removeAllSessions(definitionsService, persistenceService); TestUtils.removeAllProfiles(definitionsService, persistenceService); profileService.delete(profile.getItemId(), false); + removeItems(Session.class); segmentService.removeSegmentDefinition(SEGMENT_ID, false); // cleanup schemas @@ -560,6 +546,8 @@ public class ContextServletIT extends BaseIT { List<String> variants = contextResponse.getPersonalizations().get("perso-by-interest"); assertEquals("Invalid response code", 200, response.getStatusCode()); assertEquals("Perso should be empty, profile is empty", 0, variants.size()); + variants = contextResponse.getPersonalizationResults().get("perso-by-interest").getContentIds(); + assertEquals("Perso should be empty, profile is empty", 0, variants.size()); // set profile for matching Profile profile = profileService.load(TEST_PROFILE_ID); @@ -577,6 +565,9 @@ public class ContextServletIT extends BaseIT { assertEquals("Invalid response code", 200, response.getStatusCode()); assertEquals("Perso should contains the good number of variants", 1, variants.size()); assertEquals("Variant is not the expected one", "matching-fishing-interests-custom-score-100-variant-expected-score-120", variants.get(0)); + variants = contextResponse.getPersonalizationResults().get("perso-by-interest").getContentIds(); + assertEquals("Perso should contains the good number of variants", 1, variants.size()); + assertEquals("Variant is not the expected one", "matching-fishing-interests-custom-score-100-variant-expected-score-120", variants.get(0)); // modify profile to add interests profile = profileService.load(TEST_PROFILE_ID); @@ -617,87 +608,15 @@ public class ContextServletIT extends BaseIT { assertEquals("Variant is not the expected one", "matching-football-interests-variant-expected-score-51", variants.get(4)); assertEquals("Variant is not the expected one", "matching-tennis-interests-variant-expected-score-31", variants.get(5)); assertEquals("Variant is not the expected one", "not-matching-tennis-interests-custom-score-100-variant-expected-score-30", variants.get(6)); - } - - @Test - public void testPersonalizationWithControlGroup() throws Exception { - - Map<String, String> parameters = new HashMap<>(); - parameters.put("storeInSession", "false"); - HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); - request.setEntity( - new StringEntity(getValidatedBundleJSON("personalization-controlgroup.json", parameters), ContentType.APPLICATION_JSON)); - TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request); - assertEquals("Invalid response code", 200, response.getStatusCode()); - - ContextResponse contextResponse = response.getContextResponse(); - - Map<String, List<String>> personalizations = contextResponse.getPersonalizations(); - - validatePersonalizations(personalizations); - - // let's check that the persisted profile has the control groups; - Map<String, Object> profileProperties = contextResponse.getProfileProperties(); - List<Map<String, Object>> profileControlGroups = (List<Map<String, Object>>) profileProperties.get("unomiControlGroups"); - assertControlGroups(profileControlGroups); - - String profileId = contextResponse.getProfileId(); - Profile updatedProfile = keepTrying("Profile not found", () -> profileService.load(profileId), Objects::nonNull, - DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); - profileControlGroups = (List<Map<String, Object>>) updatedProfile.getProperty("unomiControlGroups"); - assertNotNull("Profile control groups not found in persisted profile", profileControlGroups); - assertControlGroups(profileControlGroups); - - // now let's test with session storage - parameters.put("storeInSession", "true"); - request = new HttpPost(getFullUrl(CONTEXT_URL)); - request.setEntity( - new StringEntity(getValidatedBundleJSON("personalization-controlgroup.json", parameters), ContentType.APPLICATION_JSON)); - response = TestUtils.executeContextJSONRequest(request); - assertEquals("Invalid response code", 200, response.getStatusCode()); - contextResponse = response.getContextResponse(); - - personalizations = contextResponse.getPersonalizations(); - - validatePersonalizations(personalizations); - - Map<String, Object> sessionProperties = contextResponse.getSessionProperties(); - List<Map<String, Object>> sessionControlGroups = (List<Map<String, Object>>) sessionProperties.get("unomiControlGroups"); - assertControlGroups(sessionControlGroups); - - Session updatedSession = profileService.loadSession(contextResponse.getSessionId(), new Date()); - sessionControlGroups = (List<Map<String, Object>>) updatedSession.getProperty("unomiControlGroups"); - assertNotNull("Session control groups not found in persisted session", sessionControlGroups); - assertControlGroups(sessionControlGroups); - } - - private void validatePersonalizations(Map<String, List<String>> personalizations) { - assertEquals("Personalizations don't have expected size", 2, personalizations.size()); - - List<String> perso1Contents = personalizations.get("perso1"); - assertEquals("Perso 1 content list size doesn't match", 10, perso1Contents.size()); - List<String> expectedPerso1Contents = new ArrayList<>(); - expectedPerso1Contents.add("perso1content1"); - expectedPerso1Contents.add("perso1content2"); - expectedPerso1Contents.add("perso1content3"); - expectedPerso1Contents.add("perso1content4"); - expectedPerso1Contents.add("perso1content5"); - expectedPerso1Contents.add("perso1content6"); - expectedPerso1Contents.add("perso1content7"); - expectedPerso1Contents.add("perso1content8"); - expectedPerso1Contents.add("perso1content9"); - expectedPerso1Contents.add("perso1content10"); - assertEquals("Perso1 contents do not match", expectedPerso1Contents, perso1Contents); - } - - private void assertControlGroups(List<Map<String, Object>> profileControlGroups) { - assertNotNull("Couldn't find control groups for profile", profileControlGroups); - assertTrue("Control group size should be 1", profileControlGroups.size() == 1); - Map<String, Object> controlGroup = profileControlGroups.get(0); - assertEquals("Invalid ID for control group", "perso1", controlGroup.get("id")); - assertEquals("Invalid path for control group", "/home/perso1.html", controlGroup.get("path")); - assertEquals("Invalid displayName for control group", "First perso", controlGroup.get("displayName")); - assertNotNull("Null timestamp for control group", controlGroup.get("timeStamp")); + variants = contextResponse.getPersonalizationResults().get("perso-by-interest").getContentIds(); + assertEquals("Perso should contains the good number of variants", 7, variants.size()); + assertEquals("Variant is not the expected one", "matching-fishing-interests-custom-score-100-variant-expected-score-120", variants.get(0)); + assertEquals("Variant is not the expected one", "matching-football-cars-interests-variant-expected-score-91", variants.get(1)); + assertEquals("Variant is not the expected one", "not-matching-football-cars-interests-variant-expected-score-90", variants.get(2)); + assertEquals("Variant is not the expected one", "not-matching-tennis-fishing-interests-variant-expected-score-50", variants.get(3)); + assertEquals("Variant is not the expected one", "matching-football-interests-variant-expected-score-51", variants.get(4)); + assertEquals("Variant is not the expected one", "matching-tennis-interests-variant-expected-score-31", variants.get(5)); + assertEquals("Variant is not the expected one", "not-matching-tennis-interests-custom-score-100-variant-expected-score-30", variants.get(6)); } @Test @@ -737,4 +656,223 @@ public class ContextServletIT extends BaseIT { segmentService.removeScoringDefinition(scoring.getItemId(), false); } + + @Test + public void test_no_ControlGroup() throws Exception { + performPersonalizationWithControlGroup( + null, + Collections.singletonList("no-condition"), + false, + false, + null, + null); + } + + @Test + public void test_in_ControlGroup_profile_stored() throws Exception { + performPersonalizationWithControlGroup( + generateControlGroupConfig("false", "100.0"), + Arrays.asList("first-name-missing", "no-condition"), + true, + true, + true, + null); + + performPersonalizationWithControlGroup( + generateControlGroupConfig("false", "0.0"), + Arrays.asList("first-name-missing", "no-condition"), + true, + true, + true, + null); + } + + @Test + public void test_in_ControlGroup_session_stored() throws Exception { + performPersonalizationWithControlGroup( + generateControlGroupConfig("true", "100.0"), + Arrays.asList("first-name-missing", "no-condition"), + true, + true, + null, + true); + + performPersonalizationWithControlGroup( + generateControlGroupConfig("true", "0.0"), + Arrays.asList("first-name-missing", "no-condition"), + true, + true, + null, + true); + } + + @Test + public void test_out_ControlGroup_profile_stored() throws Exception { + performPersonalizationWithControlGroup( + generateControlGroupConfig("false", "0.0"), + Collections.singletonList("no-condition"), + true, + false, + false, + null); + + performPersonalizationWithControlGroup( + generateControlGroupConfig("false", "100.0"), + Collections.singletonList("no-condition"), + true, + false, + false, + null); + } + + @Test + public void test_out_ControlGroup_session_stored() throws Exception { + performPersonalizationWithControlGroup( + generateControlGroupConfig("true", "0.0"), + Collections.singletonList("no-condition"), + true, + false, + null, + false); + + performPersonalizationWithControlGroup( + generateControlGroupConfig("true", "100.0"), + Collections.singletonList("no-condition"), + true, + false, + null, + false); + } + + @Test + public void test_advanced_ControlGroup_test() throws Exception { + // STEP 1: start with no control group + performPersonalizationWithControlGroup( + null, + Collections.singletonList("no-condition"), + false, + false, + null, + null); + + // STEP 2: then enable control group stored in profile + performPersonalizationWithControlGroup( + generateControlGroupConfig("false", "100.0"), + Arrays.asList("first-name-missing", "no-condition"), + true, + true, + true, + null); + + // STEP 3: then re disable control group + performPersonalizationWithControlGroup( + null, + Collections.singletonList("no-condition"), + false, + false, + /* We can see we still have old control group check stored in the profile */ true, + null); + + // STEP 4: then re-enable control group, but session scoped this time, with a 0 percentage + performPersonalizationWithControlGroup( + generateControlGroupConfig("true", "0.0"), + Collections.singletonList("no-condition"), + true, + false, + /* We can see we still have old control group check stored in the profile */ true, + /* And now we also have a status saved in the session */ false); + + // STEP 5: then re-enable control group, but profile scoped this time, with a 0 percentage + // We should be in control group because of the STEP 2, the current profile already contains a persisted status for the perso. + // So even if current config is 0, old check already flagged current profile to be in the control group. + performPersonalizationWithControlGroup( + generateControlGroupConfig("false", "0.0"), + Arrays.asList("first-name-missing", "no-condition"), + true, + true, + /* We can see we still have old control group check stored in the profile */ true, + /* We can see we still have old control group check stored in the session too */ false); + + // STEP 6: then re-enable control group, but session scoped this time, with a 100 percentage + // We should not be in control group because of the STEP 4, the current session already contains a persisted status for the perso. + // So even if current config is 100, old check already flagged current profile to not be in the control group. + performPersonalizationWithControlGroup( + generateControlGroupConfig("true", "100.0"), + Collections.singletonList("no-condition"), + true, + false, + /* We can see we still have old control group check stored in the profile */ true, + /* We can see we still have old control group check stored in the session too */ false); + + // STEP 7: then re disable control group + performPersonalizationWithControlGroup( + null, + Collections.singletonList("no-condition"), + false, + false, + /* We can see we still have old control group check stored in the profile */ true, + /* We can see we still have old control group check stored in the session too */ false); + } + + private void performPersonalizationWithControlGroup(Map<String, String> controlGroupConfig, List<String> expectedVariants, + boolean expectedControlGroupInfoInPersoResult, boolean expectedControlGroupValueInPersoResult, + Boolean expectedControlGroupValueInProfile, Boolean expectedControlGroupValueInSession) throws Exception { + // Test normal personalization should not have control group info in response + + HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); + if (controlGroupConfig != null) { + request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-control-group.json", controlGroupConfig), ContentType.APPLICATION_JSON)); + } else { + request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-no-control-group.json", null), ContentType.APPLICATION_JSON)); + } + + TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request); + ContextResponse contextResponse = response.getContextResponse(); + + // Check variants + List<String> variants = contextResponse.getPersonalizations().get("perso-control-group"); + assertEquals("Invalid response code", 200, response.getStatusCode()); + assertEquals("Perso should contains the good number of variants", expectedVariants.size(), variants.size()); + for (int i = 0; i < expectedVariants.size(); i++) { + assertEquals("Variant is not the expected one", expectedVariants.get(i), variants.get(i)); + } + PersonalizationResult personalizationResult = contextResponse.getPersonalizationResults().get("perso-control-group"); + variants = personalizationResult.getContentIds(); + assertEquals("Perso should contains the good number of variants", expectedVariants.size(), variants.size()); + for (int i = 0; i < expectedVariants.size(); i++) { + assertEquals("Variant is not the expected one", expectedVariants.get(i), variants.get(i)); + } + // Check control group info + assertEquals("Perso result should contains control group info", expectedControlGroupInfoInPersoResult, personalizationResult.getAdditionalResultInfos().containsKey(PersonalizationResult.ADDITIONAL_RESULT_INFO_IN_CONTROL_GROUP)); + assertEquals("Perso should not be in control group then", expectedControlGroupValueInPersoResult, contextResponse.getPersonalizationResults().get("perso-control-group").isInControlGroup()); + + // Check control group state on profile + keepTrying("Incorrect control group on profile", + () -> profileService.load(TEST_PROFILE_ID), storedProfile -> expectedControlGroupValueInProfile == getPersistedControlGroupStatus(storedProfile, "perso-control-group"), + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // Check control group state on session + keepTrying("Incorrect control group status on session", + () -> persistenceService.load(TEST_SESSION_ID, Session.class), storedSession -> expectedControlGroupValueInSession == getPersistedControlGroupStatus(storedSession, "perso-control-group"), + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + } + + private Boolean getPersistedControlGroupStatus(SystemPropertiesItem systemPropertiesItem, String personalizationId) { + if(systemPropertiesItem.getSystemProperties() != null && systemPropertiesItem.getSystemProperties().containsKey("personalizationStrategyStatus")) { + List<Map<String, Object>> personalizationStrategyStatus = (List<Map<String, Object>>) systemPropertiesItem.getSystemProperties().get("personalizationStrategyStatus"); + for (Map<String, Object> strategyStatus : personalizationStrategyStatus) { + if (personalizationId.equals(strategyStatus.get("personalizationId"))) { + return strategyStatus.containsKey("inControlGroup") && ((boolean) strategyStatus.get("inControlGroup")); + } + } + } + return null; + } + + private Map<String, String> generateControlGroupConfig(String storeInSession, String percentage) { + Map<String, String> controlGroupConfig = new HashMap<>(); + controlGroupConfig.put("storeInSession", storeInSession); + controlGroupConfig.put("percentage", percentage); + return controlGroupConfig; + } } diff --git a/itests/src/test/resources/personalization-control-group.json b/itests/src/test/resources/personalization-control-group.json new file mode 100644 index 000000000..e7b07eb2b --- /dev/null +++ b/itests/src/test/resources/personalization-control-group.json @@ -0,0 +1,49 @@ +{ + "source": { + "itemId": "CMSServer", + "itemType": "custom", + "scope": "acme", + "properties": {} + }, + "requireSegments": true, + "requiredProfileProperties": [ + "*" + ], + "requiredSessionProperties": [ + "*" + ], + "personalizations": [ + { + "id": "perso-control-group", + "strategy": "matching-first", + "strategyOptions": { + "controlGroup": { + "storeInSession": ###storeInSession###, + "percentage": ###percentage### + } + }, + "contents": [ + { + "id": "first-name-missing", + "filters": [ + { + "condition": { + "type": "profilePropertyCondition", + "parameterValues": { + "propertyName": "properties.firstName", + "comparisonOperator": "missing" + } + } + } + ] + }, + { + "id": "no-condition", + "filters": null + } + ] + } + ], + "sessionId": "dummy-session", + "profileId": "test-profile-id" +} \ No newline at end of file diff --git a/itests/src/test/resources/personalization-controlgroup.json b/itests/src/test/resources/personalization-controlgroup.json deleted file mode 100644 index c4a1bcf98..000000000 --- a/itests/src/test/resources/personalization-controlgroup.json +++ /dev/null @@ -1,988 +0,0 @@ -{ - "source": { - "itemId": "CMSServer", - "itemType": "custom", - "scope": "acme", - "properties": {} - }, - "requireSegments": true, - "requiredProfileProperties": [ - "unomiControlGroups" - ], - "requiredSessionProperties": [ - "unomiControlGroups" - ], - "personalizations": [ - { - "id": "perso1", - "strategy": "score-sorted", - "strategyOptions": { - "threshold": -1, - "controlGroup" : { - "percentage" : 100.0, - "displayName" : "First perso", - "path" : "/home/perso1.html", - "storeInSession" : ###storeInSession### - } - }, - "contents": [ - { - "id": "perso1content1", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ], - "properties": { - "interests": "health food" - } - }, - { - "id": "perso1content2", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/contactus.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso1content3", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/documentation.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso1content4", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/aboutus.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso1content5", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/products.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso1content6", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/services.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso1content7", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/community.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso1content8", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/projects.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso1content9", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/home.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso1content10", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/theend.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - } - ] - }, - { - "id": "perso2", - "strategy": "score-sorted", - "strategyOptions": { - "threshold": -1 - }, - "contents": [ - { - "id": "perso2content1", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ], - "properties": { - "interests": "health food" - } - }, - { - "id": "perso2content2", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/contactus.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso1content3", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/documentation.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso2content4", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/aboutus.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso2content5", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/products.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso2content6", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/services.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso2content7", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/community.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso2content8", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/projects.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso2content9", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/home.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - }, - { - "id": "perso2content10", - "filters": [ - { - "condition": { - "parameterValues": { - "minimumEventCount": 1, - "eventCondition": { - "type": "booleanCondition", - "parameterValues": { - "operator": "and", - "subConditions" : [ - { - "type": "eventTypeCondition", - "parameterValues": { - "eventTypeId": "view" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.pagePath", - "propertyValue": "/theend.html", - "comparisonOperator": "equals" - } - }, - { - "type": "eventPropertyCondition", - "parameterValues": { - "propertyName": "target.properties.pageInfo.language", - "propertyValue": "en", - "comparisonOperator": "equals" - } - } - ] - } - }, - "numberOfDays": 30 - }, - "type": "pastEventCondition" - }, - "properties": { - "score": -1000 - } - } - ] - } - ] - } - ], - "sessionId": "test-session-id" -} \ No newline at end of file diff --git a/itests/src/test/resources/personalization-no-control-group.json b/itests/src/test/resources/personalization-no-control-group.json new file mode 100644 index 000000000..790dbe71b --- /dev/null +++ b/itests/src/test/resources/personalization-no-control-group.json @@ -0,0 +1,45 @@ +{ + "source": { + "itemId": "CMSServer", + "itemType": "custom", + "scope": "acme", + "properties": {} + }, + "requireSegments": true, + "requiredProfileProperties": [ + "*" + ], + "requiredSessionProperties": [ + "*" + ], + "personalizations": [ + { + "id": "perso-control-group", + "strategy": "matching-first", + "strategyOptions": { + }, + "contents": [ + { + "id": "first-name-missing", + "filters": [ + { + "condition": { + "type": "profilePropertyCondition", + "parameterValues": { + "propertyName": "properties.firstName", + "comparisonOperator": "missing" + } + } + } + ] + }, + { + "id": "no-condition", + "filters": null + } + ] + } + ], + "sessionId": "dummy-session", + "profileId": "test-profile-id" +} \ No newline at end of file diff --git a/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java b/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java index 6207845f8..c6f98bd37 100644 --- a/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java +++ b/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java @@ -203,11 +203,11 @@ public class ContextJsonEndpoint { List<PersonalizationService.PersonalizationRequest> personalizations = contextRequest.getPersonalizations(); if (personalizations != null) { - data.setPersonalizations(new HashMap<>()); + data.setPersonalizationResults(new HashMap<>()); for (PersonalizationService.PersonalizationRequest personalization : sanitizePersonalizations(personalizations)) { PersonalizationResult personalizationResult = personalizationService.personalizeList(eventsRequestContext.getProfile(), eventsRequestContext.getSession(), personalization); eventsRequestContext.addChanges(personalizationResult.getChangeType()); - data.getPersonalizations().put(personalization.getId(), personalizationResult.getContentIds()); + data.getPersonalizationResults().put(personalization.getId(), personalizationResult); } } diff --git a/services/src/main/java/org/apache/unomi/services/impl/personalization/ControlGroup.java b/services/src/main/java/org/apache/unomi/services/impl/personalization/ControlGroup.java deleted file mode 100644 index 795080e87..000000000 --- a/services/src/main/java/org/apache/unomi/services/impl/personalization/ControlGroup.java +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.unomi.services.impl.personalization; - -import org.apache.unomi.persistence.spi.CustomObjectMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.text.ParseException; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * Represents a personalization control group, stored in a profile and/or a session - */ -public class ControlGroup { - - private static final Logger logger = LoggerFactory.getLogger(ControlGroup.class.getName()); - - String id; - String displayName; - String path; - Date timeStamp; - - public ControlGroup(String id, String displayName, String path, Date timeStamp) { - this.id = id; - this.displayName = displayName; - this.path = path; - this.timeStamp = timeStamp; - } - - public static ControlGroup fromMap(Map<String,Object> map) { - String id = (String) map.get("id"); - String displayName = (String) map.get("displayName"); - String path = (String) map.get("path"); - String dateStr = (String) map.get("timeStamp"); - Date date = null; - try { - date = CustomObjectMapper.getObjectMapper().getDateFormat().parse(dateStr); - } catch (ParseException e) { - logger.error("Error parsing control group date", e); - } - return new ControlGroup(id, displayName, path, date); - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getDisplayName() { - return displayName; - } - - public void setDisplayName(String displayName) { - this.displayName = displayName; - } - - public String getPath() { - return path; - } - - public void setPath(String path) { - this.path = path; - } - - public Date getTimeStamp() { - return timeStamp; - } - - public void setTimeStamp(Date timeStamp) { - this.timeStamp = timeStamp; - } - - public Map<String,Object> toMap() { - Map<String,Object> result = new LinkedHashMap<>(); - result.put("id", id); - result.put("displayName", displayName); - result.put("path", path); - result.put("timeStamp", CustomObjectMapper.getObjectMapper().getDateFormat().format(timeStamp)); - return result; - } -} diff --git a/services/src/main/java/org/apache/unomi/services/impl/personalization/PersonalizationServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/personalization/PersonalizationServiceImpl.java index 1d2cfd760..494b8db66 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/personalization/PersonalizationServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/personalization/PersonalizationServiceImpl.java @@ -17,30 +17,27 @@ package org.apache.unomi.services.impl.personalization; -import org.apache.unomi.api.PersonalizationResult; -import org.apache.unomi.api.PersonalizationStrategy; -import org.apache.unomi.api.Profile; -import org.apache.unomi.api.Session; +import org.apache.unomi.api.*; import org.apache.unomi.api.conditions.Condition; -import org.apache.unomi.api.services.EventService; import org.apache.unomi.api.services.PersonalizationService; import org.apache.unomi.api.services.ProfileService; +import org.apache.unomi.services.sorts.ControlGroupPersonalizationStrategy; import org.osgi.framework.BundleContext; import org.osgi.framework.ServiceReference; import java.util.*; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; + +import static org.apache.unomi.services.sorts.ControlGroupPersonalizationStrategy.CONTROL_GROUP_CONFIG; public class PersonalizationServiceImpl implements PersonalizationService { - public static final String CONTROL_GROUPS_PROPERTY_NAME = "unomiControlGroups"; private BundleContext bundleContext; private ProfileService profileService; private Map<String, PersonalizationStrategy> personalizationStrategies = new ConcurrentHashMap<>(); + private final PersonalizationStrategy controlGroupStrategy = new ControlGroupPersonalizationStrategy(); - private Random controlGroupRandom = new Random(); public void setProfileService(ProfileService profileService) { this.profileService = profileService; @@ -88,73 +85,26 @@ public class PersonalizationServiceImpl implements PersonalizationService { @Override public PersonalizationResult personalizeList(Profile profile, Session session, PersonalizationRequest personalizationRequest) { PersonalizationStrategy strategy = personalizationStrategies.get(personalizationRequest.getStrategy()); - int changeType = EventService.NO_CHANGE; if (strategy != null) { - if (personalizationRequest.getStrategyOptions() != null && personalizationRequest.getStrategyOptions().containsKey("controlGroup")) { - Map<String,Object> controlGroupMap = (Map<String,Object>) personalizationRequest.getStrategyOptions().get("controlGroup"); - - boolean storeInSession = false; - if (controlGroupMap.containsKey("storeInSession")) { - storeInSession = (Boolean) controlGroupMap.get("storeInSession"); + // hook on control group if necessary + PersonalizationResult controlGroupStrategyResult = null; + if (personalizationRequest.getStrategyOptions() != null && personalizationRequest.getStrategyOptions().containsKey(CONTROL_GROUP_CONFIG)) { + controlGroupStrategyResult = controlGroupStrategy.personalizeList(profile, session, personalizationRequest); + if (controlGroupStrategyResult.isInControlGroup()) { + return controlGroupStrategyResult; } + } - boolean profileInControlGroup = false; - Optional<ControlGroup> currentControlGroup; - - List<ControlGroup> controlGroups = null; - if (storeInSession) { - if (session.getProperty(CONTROL_GROUPS_PROPERTY_NAME) != null) { - controlGroups = ((List<Map<String, Object>>) session.getProperty(CONTROL_GROUPS_PROPERTY_NAME)).stream().map(ControlGroup::fromMap).collect(Collectors.toList()); - } - } else { - if (profile.getProperty(CONTROL_GROUPS_PROPERTY_NAME) != null) { - controlGroups = ((List<Map<String, Object>>) profile.getProperty(CONTROL_GROUPS_PROPERTY_NAME)).stream().map(ControlGroup::fromMap).collect(Collectors.toList()); - } - } - if (controlGroups == null) { - controlGroups = new ArrayList<>(); - } - currentControlGroup = controlGroups.stream().filter(controlGroup -> controlGroup.id.equals(personalizationRequest.getId())).findFirst(); - if (currentControlGroup.isPresent()) { - // we already have an entry for this personalization so this means the profile is in the control group - profileInControlGroup = true; - } else { - double randomDouble = controlGroupRandom.nextDouble() * 100.0; - Object percentageObject = controlGroupMap.get("percentage"); - Double controlGroupPercentage = null; - if (percentageObject != null) { - if (percentageObject instanceof Double) { - controlGroupPercentage = (Double) percentageObject; - } else if (percentageObject instanceof Integer) { - controlGroupPercentage = ((Integer) percentageObject).doubleValue(); - } - } - - if (randomDouble <= controlGroupPercentage) { - // Profile is elected to be in control group - profileInControlGroup = true; - ControlGroup controlGroup = new ControlGroup(personalizationRequest.getId(), - (String) controlGroupMap.get("displayName"), - (String) controlGroupMap.get("path"), - new Date()); - controlGroups.add(controlGroup); - List<Map<String,Object>> controlGroupsMap = controlGroups.stream().map(ControlGroup::toMap).collect(Collectors.toList()); - if (storeInSession) { - session.setProperty(CONTROL_GROUPS_PROPERTY_NAME, controlGroupsMap); - changeType = EventService.SESSION_UPDATED; - } else { - profile.setProperty(CONTROL_GROUPS_PROPERTY_NAME, controlGroupsMap); - changeType = EventService.PROFILE_UPDATED; - } - } - } - if (profileInControlGroup) { - // if profile is in control group we return the unmodified list. - return new PersonalizationResult(personalizationRequest.getContents().stream().map(PersonalizedContent::getId).collect(Collectors.toList()), changeType); - } + // Execute the original strategy + PersonalizationResult originalStrategyResult = strategy.personalizeList(profile, session, personalizationRequest); + // merge original strategy result with previous controlGroup hook in case it's needed + if (controlGroupStrategyResult != null) { + originalStrategyResult.addChanges(controlGroupStrategyResult.getChangeType()); + originalStrategyResult.getAdditionalResultInfos().putAll(controlGroupStrategyResult.getAdditionalResultInfos()); } - return new PersonalizationResult(strategy.personalizeList(profile, session, personalizationRequest), changeType); + + return originalStrategyResult; } throw new IllegalArgumentException("Unknown strategy : "+ personalizationRequest.getStrategy()); diff --git a/services/src/main/java/org/apache/unomi/services/sorts/ControlGroupPersonalizationStrategy.java b/services/src/main/java/org/apache/unomi/services/sorts/ControlGroupPersonalizationStrategy.java new file mode 100644 index 000000000..3297b334c --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/sorts/ControlGroupPersonalizationStrategy.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.sorts; + +import org.apache.unomi.api.*; +import org.apache.unomi.api.services.EventService; +import org.apache.unomi.api.services.PersonalizationService; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * System strategy to calculate control group on personalization that would use such configuration + * The status is then stored in the current profile/session under: systemProperties.personalizationStrategyStatus + * A control group is used with a percentage to decide if the current profile/session should have personalized results or not. + * - in case of control group is true: the variants will be return untouched like received and an information will be added to the personalized results to warn the client + * - in case of control group is false: we will execute the personalization normally + */ +public class ControlGroupPersonalizationStrategy implements PersonalizationStrategy { + public static final String PERSONALIZATION_STRATEGY_STATUS = "personalizationStrategyStatus"; + public static final String PERSONALIZATION_STRATEGY_STATUS_ID = "personalizationId"; + public static final String PERSONALIZATION_STRATEGY_STATUS_IN_CTRL_GROUP = "inControlGroup"; + public static final String PERSONALIZATION_STRATEGY_STATUS_DATE = "timeStamp"; + + public static final String CONTROL_GROUP_CONFIG_STORE_IN_SESSION = "storeInSession"; + public static final String CONTROL_GROUP_CONFIG_PERCENTAGE = "percentage"; + public static final String CONTROL_GROUP_CONFIG = "controlGroup"; + + private final Random controlGroupRandom = new Random(); + + @Override + public PersonalizationResult personalizeList(Profile profile, Session session, PersonalizationService.PersonalizationRequest personalizationRequest) { + if (personalizationRequest.getStrategyOptions() != null && personalizationRequest.getStrategyOptions().containsKey(CONTROL_GROUP_CONFIG)) { + Map<String, Object> controlGroupMap = (Map<String, Object>) personalizationRequest.getStrategyOptions().get(CONTROL_GROUP_CONFIG); + + return controlGroupMap.containsKey(CONTROL_GROUP_CONFIG_STORE_IN_SESSION) && + controlGroupMap.get(CONTROL_GROUP_CONFIG_STORE_IN_SESSION) instanceof Boolean && + ((Boolean) controlGroupMap.get(CONTROL_GROUP_CONFIG_STORE_IN_SESSION)) ? + getPersonalizationResultForControlGroup(personalizationRequest, session, controlGroupMap, EventService.SESSION_UPDATED) : + getPersonalizationResultForControlGroup(personalizationRequest, profile, controlGroupMap, EventService.PROFILE_UPDATED); + } + + throw new IllegalArgumentException("Not possible to perform control group strategy without control group config"); + } + + private PersonalizationResult getPersonalizationResultForControlGroup(PersonalizationService.PersonalizationRequest personalizationRequest, SystemPropertiesItem systemPropertiesItem, Map<String, Object> controlGroupConfig, int changeType) { + // Control group will return the same untouched list of received content ids + PersonalizationResult personalizationResult = new PersonalizationResult( + personalizationRequest.getContents().stream().map(PersonalizationService.PersonalizedContent::getId).collect(Collectors.toList())); + + // get the list of existing personalization strategy status + List<Map<String, Object>> strategyStatuses; + if (systemPropertiesItem.getSystemProperties().get(PERSONALIZATION_STRATEGY_STATUS) == null) { + strategyStatuses = new ArrayList<>(); + systemPropertiesItem.getSystemProperties().put(PERSONALIZATION_STRATEGY_STATUS, strategyStatuses); + } else { + strategyStatuses = (List<Map<String, Object>>) systemPropertiesItem.getSystemProperties().get(PERSONALIZATION_STRATEGY_STATUS); + } + + // Check if we need to update an old status that would not contains control group info + boolean inControlGroup; + for (Map<String, Object> oldStrategyStatus : strategyStatuses) { + if (personalizationRequest.getId().equals(oldStrategyStatus.get(PERSONALIZATION_STRATEGY_STATUS_ID))) { + // Check if we have to update the strategy status or not ? + if (!oldStrategyStatus.containsKey(PERSONALIZATION_STRATEGY_STATUS_IN_CTRL_GROUP)) { + + // Old status doesn't contain any control group check, we need to calculate it and update the old status with the result. + inControlGroup = calculateControlGroup(controlGroupConfig); + oldStrategyStatus.put(PERSONALIZATION_STRATEGY_STATUS_IN_CTRL_GROUP, inControlGroup); + oldStrategyStatus.put(PERSONALIZATION_STRATEGY_STATUS_DATE, new Date()); + personalizationResult.addChanges(changeType); + + } else { + // Just read existing status about the control group + inControlGroup = (boolean) oldStrategyStatus.get(PERSONALIZATION_STRATEGY_STATUS_IN_CTRL_GROUP); + } + + personalizationResult.setInControlGroup(inControlGroup); + return personalizationResult; + } + } + + // We didn't found any existing status for the current perso, we need to create a new one. + inControlGroup = calculateControlGroup(controlGroupConfig); + Map<String, Object> newStrategyStatus = new HashMap<>(); + newStrategyStatus.put(PERSONALIZATION_STRATEGY_STATUS_ID, personalizationRequest.getId()); + newStrategyStatus.put(PERSONALIZATION_STRATEGY_STATUS_DATE, new Date()); + newStrategyStatus.put(PERSONALIZATION_STRATEGY_STATUS_IN_CTRL_GROUP, inControlGroup); + strategyStatuses.add(newStrategyStatus); + + personalizationResult.addChanges(changeType); + personalizationResult.setInControlGroup(inControlGroup); + return personalizationResult; + } + + private boolean calculateControlGroup(Map<String,Object> controlGroupConfig) { + double percentage = (controlGroupConfig.get(CONTROL_GROUP_CONFIG_PERCENTAGE) != null && controlGroupConfig.get(CONTROL_GROUP_CONFIG_PERCENTAGE) instanceof Number) ? + ((Number) controlGroupConfig.get(CONTROL_GROUP_CONFIG_PERCENTAGE)).doubleValue() : 0; + + double random = controlGroupRandom.nextDouble() * 100.0; + return random <= percentage; + } +} diff --git a/services/src/main/java/org/apache/unomi/services/sorts/FilterPersonalizationStrategy.java b/services/src/main/java/org/apache/unomi/services/sorts/FilterPersonalizationStrategy.java index 809a724d6..a79bc62bf 100644 --- a/services/src/main/java/org/apache/unomi/services/sorts/FilterPersonalizationStrategy.java +++ b/services/src/main/java/org/apache/unomi/services/sorts/FilterPersonalizationStrategy.java @@ -17,6 +17,7 @@ package org.apache.unomi.services.sorts; +import org.apache.unomi.api.PersonalizationResult; import org.apache.unomi.api.Profile; import org.apache.unomi.api.Session; import org.apache.unomi.api.PersonalizationStrategy; @@ -39,7 +40,7 @@ public class FilterPersonalizationStrategy implements PersonalizationStrategy { } @Override - public List<String> personalizeList(Profile profile, Session session, PersonalizationService.PersonalizationRequest personalizationRequest) { + public PersonalizationResult personalizeList(Profile profile, Session session, PersonalizationService.PersonalizationRequest personalizationRequest) { List<String> sortedContent = new ArrayList<>(); for (PersonalizationService.PersonalizedContent personalizedContent : personalizationRequest.getContents()) { boolean result = true; @@ -59,6 +60,6 @@ public class FilterPersonalizationStrategy implements PersonalizationStrategy { sortedContent.add(fallback); } - return sortedContent; + return new PersonalizationResult(sortedContent); } } diff --git a/services/src/main/java/org/apache/unomi/services/sorts/RandomPersonalizationStrategy.java b/services/src/main/java/org/apache/unomi/services/sorts/RandomPersonalizationStrategy.java index 3dd31f78b..76bb168af 100644 --- a/services/src/main/java/org/apache/unomi/services/sorts/RandomPersonalizationStrategy.java +++ b/services/src/main/java/org/apache/unomi/services/sorts/RandomPersonalizationStrategy.java @@ -17,19 +17,19 @@ package org.apache.unomi.services.sorts; +import org.apache.unomi.api.PersonalizationResult; import org.apache.unomi.api.Profile; import org.apache.unomi.api.Session; import org.apache.unomi.api.services.PersonalizationService; import java.util.Collections; -import java.util.List; public class RandomPersonalizationStrategy extends FilterPersonalizationStrategy { @Override - public List<String> personalizeList(Profile profile, Session session, PersonalizationService.PersonalizationRequest personalizationRequest) { - List<String> r = super.personalizeList(profile, session, personalizationRequest); - Collections.shuffle(r); + public PersonalizationResult personalizeList(Profile profile, Session session, PersonalizationService.PersonalizationRequest personalizationRequest) { + PersonalizationResult r = super.personalizeList(profile, session, personalizationRequest); + Collections.shuffle(r.getContentIds()); return r; } } diff --git a/services/src/main/java/org/apache/unomi/services/sorts/ScorePersonalizationStrategy.java b/services/src/main/java/org/apache/unomi/services/sorts/ScorePersonalizationStrategy.java index 82bcc165b..93d6c0ebe 100644 --- a/services/src/main/java/org/apache/unomi/services/sorts/ScorePersonalizationStrategy.java +++ b/services/src/main/java/org/apache/unomi/services/sorts/ScorePersonalizationStrategy.java @@ -17,6 +17,7 @@ package org.apache.unomi.services.sorts; +import org.apache.unomi.api.PersonalizationResult; import org.apache.unomi.api.Profile; import org.apache.unomi.api.Session; import org.apache.unomi.api.PersonalizationStrategy; @@ -35,7 +36,7 @@ public class ScorePersonalizationStrategy implements PersonalizationStrategy { } @Override - public List<String> personalizeList(Profile profile, Session session, PersonalizationService.PersonalizationRequest personalizationRequest) { + public PersonalizationResult personalizeList(Profile profile, Session session, PersonalizationService.PersonalizationRequest personalizationRequest) { List<String> sortedContent = new ArrayList<>(); final Map<String,Integer> t = new HashMap<>(); @@ -64,7 +65,7 @@ public class ScorePersonalizationStrategy implements PersonalizationStrategy { String scoringPlanList = (String) (personalizedContent.getProperties() != null ? personalizedContent.getProperties().get("scoringPlans") : null); if (scoringPlanList != null) { - Map<String,Integer> scoreValues = (Map<String, Integer>) profile.getScores(); + Map<String,Integer> scoreValues = profile.getScores(); for (String scoringPlan : scoringPlanList.split(" ")) { if (scoreValues.get(scoringPlan) != null) { score += scoreValues.get(scoringPlan); @@ -93,18 +94,14 @@ public class ScorePersonalizationStrategy implements PersonalizationStrategy { sortedContent.add(personalizedContent.getId()); } } - Collections.sort(sortedContent, new Comparator<String>() { - @Override - public int compare(String o1, String o2) { - return t.get(o2) - t.get(o1); - } - }); + + sortedContent.sort((o1, o2) -> t.get(o2) - t.get(o1)); String fallback = (String) personalizationRequest.getStrategyOptions().get("fallback"); if (fallback != null && !sortedContent.contains(fallback)) { sortedContent.add(fallback); } - return sortedContent; + return new PersonalizationResult(sortedContent); } }