This is an automated email from the ASF dual-hosted git repository. shuber pushed a commit to branch UNOMI-537-control-groups in repository https://gitbox.apache.org/repos/asf/unomi.git
commit 8b72d316b76737682937082eab928e9cab3028ce Author: Serge Huber <[email protected]> AuthorDate: Mon Dec 20 11:39:35 2021 +0100 UNOMI-537 Add control groups to personalizations - Personalizations will now store a control group election in the session or in the profile - Updated documentation for personalization - Implemented integration tests for control groups --- ...ionStrategy.java => PersonalizationResult.java} | 24 +- .../apache/unomi/api/PersonalizationStrategy.java | 12 +- .../unomi/api/services/PersonalizationService.java | 3 +- .../org/apache/unomi/itests/ContextServletIT.java | 83 +- .../resources/personalization-controlgroup.json | 1031 ++++++++++++++++++++ itests/src/test/resources/personalization.json | 2 +- .../src/main/asciidoc/samples/twitter-sample.adoc | 29 +- .../unomi/rest/endpoints/ContextJsonEndpoint.java | 46 +- .../main/java/org/apache/unomi/utils/Changes.java | 4 + .../impl/personalization/ControlGroup.java | 68 ++ .../PersonalizationServiceImpl.java | 69 +- .../sorts/FilterPersonalizationStrategy.java | 3 + 12 files changed, 1326 insertions(+), 48 deletions(-) diff --git a/api/src/main/java/org/apache/unomi/api/PersonalizationStrategy.java b/api/src/main/java/org/apache/unomi/api/PersonalizationResult.java similarity index 59% copy from api/src/main/java/org/apache/unomi/api/PersonalizationStrategy.java copy to api/src/main/java/org/apache/unomi/api/PersonalizationResult.java index 9c76d41..446189a 100644 --- a/api/src/main/java/org/apache/unomi/api/PersonalizationStrategy.java +++ b/api/src/main/java/org/apache/unomi/api/PersonalizationResult.java @@ -16,13 +16,27 @@ */ package org.apache.unomi.api; -import org.apache.unomi.api.services.PersonalizationService; - import java.util.List; /** - * + * 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). */ -public interface PersonalizationStrategy { - List<String> personalizeList(Profile profile, Session session, PersonalizationService.PersonalizationRequest personalizationRequest); +public class PersonalizationResult { + + 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; + } } 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 9c76d41..152625e 100644 --- a/api/src/main/java/org/apache/unomi/api/PersonalizationStrategy.java +++ b/api/src/main/java/org/apache/unomi/api/PersonalizationStrategy.java @@ -21,8 +21,18 @@ import org.apache.unomi.api.services.PersonalizationService; import java.util.List; /** - * + * Interface for personalization strategies. Will filter and reorder the content list according to the strategy + * implementation */ public interface PersonalizationStrategy { + + /** + * Filters and personalizes the list of contents passed as a parameter using the strategy's implementation. + * @param profile the profile to use for the personalization + * @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 + */ List<String> personalizeList(Profile profile, Session session, PersonalizationService.PersonalizationRequest personalizationRequest); } diff --git a/api/src/main/java/org/apache/unomi/api/services/PersonalizationService.java b/api/src/main/java/org/apache/unomi/api/services/PersonalizationService.java index ad6e3a6..f4dea3d 100644 --- a/api/src/main/java/org/apache/unomi/api/services/PersonalizationService.java +++ b/api/src/main/java/org/apache/unomi/api/services/PersonalizationService.java @@ -17,6 +17,7 @@ package org.apache.unomi.api.services; +import org.apache.unomi.api.PersonalizationResult; import org.apache.unomi.api.Profile; import org.apache.unomi.api.Session; import org.apache.unomi.api.conditions.Condition; @@ -57,7 +58,7 @@ public interface PersonalizationService { * @param personalizationRequest Personalization request, containing the list of variants and the required strategy * @return List of ids, based on user profile */ - List<String> personalizeList(Profile profile, Session session, PersonalizationRequest personalizationRequest); + PersonalizationResult personalizeList(Profile profile, Session session, PersonalizationRequest personalizationRequest); /** * Personalization request 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 c3e2b3d..697fc1e 100644 --- a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java @@ -24,7 +24,6 @@ import org.apache.http.entity.StringEntity; 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.ScoringElement; import org.apache.unomi.api.segments.Segment; import org.apache.unomi.api.services.DefinitionsService; import org.apache.unomi.api.services.EventService; @@ -560,6 +559,88 @@ public class ContextServletIT extends BaseIT { } @Test + public void testPersonalizationWithControlGroup() throws IOException, InterruptedException { + + Map<String,String> parameters = new HashMap<>(); + parameters.put("storeInSession", "false"); + HttpPost request = new HttpPost(URL + CONTEXT_URL); + request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-controlgroup.json", parameters), ContentType.create("application/json"))); + TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request); + assertEquals("Invalid response code", 200, response.getStatusCode()); + refreshPersistence(); + Thread.sleep(2000); //Making sure event is updated in DB + 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); + + Profile updatedProfile = profileService.load(contextResponse.getProfileId()); + 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(URL + CONTEXT_URL); + request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-controlgroup.json", parameters), ContentType.create("application/json"))); + response = TestUtils.executeContextJSONRequest(request); + assertEquals("Invalid response code", 200, response.getStatusCode()); + refreshPersistence(); + Thread.sleep(2000); //Making sure event is updated in DB + 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")); + } + + + @Test public void testRequireScoring() throws IOException, InterruptedException { Map<String,String> parameters = new HashMap<>(); diff --git a/itests/src/test/resources/personalization-controlgroup.json b/itests/src/test/resources/personalization-controlgroup.json new file mode 100644 index 0000000..8a931d7 --- /dev/null +++ b/itests/src/test/resources/personalization-controlgroup.json @@ -0,0 +1,1031 @@ +{ + "source": { + "itemId": "CMSServer", + "itemType": "custom", + "scope": "acme", + "version": null, + "properties": {} + }, + "requireSegments": true, + "requiredProfileProperties": [ + "unomiControlGroups" + ], + "requiredSessionProperties": [ + "unomiControlGroups" + ], + "events": null, + "filters": null, + "personalizations": [ + { + "id": "perso1", + "strategy": "score-sorted", + "strategyOptions": { + "threshold": -1, + "controlGroup" : { + "percentage" : 1.0, + "displayName" : "First perso", + "path" : "/home/perso1.html", + "storeInSession" : ###storeInSession### + } + }, + "contents": [ + { + "id": "perso1content1", + "filters": [ + { + "appliesOn": null, + "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": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso1content3", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso1content4", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso1content5", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso1content6", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso1content7", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso1content8", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso1content9", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso1content10", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + } + ] + }, + { + "id": "perso2", + "strategy": "score-sorted", + "strategyOptions": { + "threshold": -1 + }, + "contents": [ + { + "id": "perso2content1", + "filters": [ + { + "appliesOn": null, + "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": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso1content3", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso2content4", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso2content5", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso2content6", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso2content7", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso2content8", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso2content9", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + }, + { + "id": "perso2content10", + "filters": [ + { + "appliesOn": null, + "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 + } + } + ], + "properties": null + } + ] + } + ], + "profileOverrides": null, + "sessionPropertiesOverrides": null, + "sessionId": "test-session-id" +} \ No newline at end of file diff --git a/itests/src/test/resources/personalization.json b/itests/src/test/resources/personalization.json index d0461e9..89ac156 100644 --- a/itests/src/test/resources/personalization.json +++ b/itests/src/test/resources/personalization.json @@ -8,7 +8,7 @@ }, "requireSegments": true, "requiredProfileProperties": [ - "interests" + "*" ], "requiredSessionProperties": [ "*" diff --git a/manual/src/main/asciidoc/samples/twitter-sample.adoc b/manual/src/main/asciidoc/samples/twitter-sample.adoc index 16f8255..011b4b3 100644 --- a/manual/src/main/asciidoc/samples/twitter-sample.adoc +++ b/manual/src/main/asciidoc/samples/twitter-sample.adoc @@ -288,7 +288,7 @@ curl --location --request POST 'http://localhost:8181/cxs/context.json' \ "source": null, "requireSegments": false, "requiredProfileProperties": null, - "requiredSessionProperties": null, + "requiredSessionProperties": [ "unomiControlGroups" ], "events": null, "filters": null, "personalizations": [ @@ -296,7 +296,13 @@ curl --location --request POST 'http://localhost:8181/cxs/context.json' \ "id": "gender-test", "strategy": "matching-first", "strategyOptions": { - "fallback": "var2" + "fallback": "var2", + "controlGroup" : { + "percentage" : 0.1, + "displayName" : "Gender test control group", + "path" : "/gender-test", + "storeInSession" : true + } }, "contents": [ { @@ -333,13 +339,12 @@ curl --location --request POST 'http://localhost:8181/cxs/context.json' \ In the above example, we basically setup two variants : `var1` and `var2` and setup the `var2` to be the fallback variant in case no variant is matched. We could of course specify more than a variant. The `strategy` indicates to the -personalization service how to calculate the "winning" variant. In this case the strategy `matching-first` will return -the first variant that matches the current profile. +personalization service how to calculate the "winning" variant. In this case the strategy `matching-first` will return variants that match the current profile. We also use the `controlGroups` option to specify that we want to have a control group for this personalization. The `0.1` percentage value represents 10% (0 to 1) of traffic that will be assigned randomly to the control group. The control group will be stored in the profile and the session of the visitors if they were assigned to [...] Currently the following strategies are available: -- `matching-first`: will return the first matching variant. -- `random`: will return a random variant +- `matching-first`: will return the variant IDs that match the current profile (using the initial content order) +- `random`: will return a shuffled list of variant IDs (ignoring any conditions) - `score-sorted`: allows to sort the variants based on scores associated with the filtering conditions, effectively sorting them by the highest scoring condition first. @@ -351,7 +356,16 @@ Here is the result of the above example: "profileId": "01060c4c-a055-4c8f-9692-8a699d0c434a", "sessionId": "demo-session-id", "profileProperties": null, - "sessionProperties": null, + "sessionProperties": { + "unomiControlGroups": [ + { + "id": "previousPerso", + "displayName": "Previous perso", + "path": "/home/previousPerso.html", + "timeStamp": "2021-12-15T13:52:38Z" + } + ] + }, "profileSegments": null, "filteringResults": null, "processedEvents": 0, @@ -367,6 +381,7 @@ Here is the result of the above example: } ---- +In the above example we can see the profile and session were assigned to other control groups but not the current one (the ids are different). ====== Overrides 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 3184e60..2bb0c02 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 @@ -20,13 +20,7 @@ package org.apache.unomi.rest.endpoints; import com.fasterxml.jackson.core.JsonProcessingException; import org.apache.commons.lang3.StringUtils; import org.apache.cxf.rs.security.cors.CrossOriginResourceSharing; -import org.apache.unomi.api.ContextRequest; -import org.apache.unomi.api.ContextResponse; -import org.apache.unomi.api.Event; -import org.apache.unomi.api.Persona; -import org.apache.unomi.api.PersonaWithSessions; -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.ConfigSharingService; import org.apache.unomi.api.services.EventService; @@ -378,6 +372,26 @@ public class ContextJsonEndpoint { Changes changes = restServiceUtils.handleEvents(contextRequest.getEvents(), session, profile, request, response, timestamp); data.setProcessedEvents(changes.getProcessedItems()); + List<PersonalizationService.PersonalizedContent> filterNodes = contextRequest.getFilters(); + if (filterNodes != null) { + data.setFilteringResults(new HashMap<>()); + for (PersonalizationService.PersonalizedContent personalizedContent : sanitizePersonalizedContentObjects(filterNodes)) { + data.getFilteringResults() + .put(personalizedContent.getId(), personalizationService.filter(profile, session, personalizedContent)); + } + } + + List<PersonalizationService.PersonalizationRequest> personalizations = contextRequest.getPersonalizations(); + if (personalizations != null) { + data.setPersonalizations(new HashMap<>()); + for (PersonalizationService.PersonalizationRequest personalization : sanitizePersonalizations(personalizations)) { + PersonalizationResult personalizationResult = personalizationService.personalizeList(profile, session, personalization); + changes.setChangeType(changes.getChangeType() | personalizationResult.getChangeType()); + data.getPersonalizations() + .put(personalization.getId(), personalizationResult.getContentIds()); + } + } + profile = changes.getProfile(); if (contextRequest.isRequireSegments()) { @@ -408,24 +422,6 @@ public class ContextJsonEndpoint { processOverrides(contextRequest, profile, session); - List<PersonalizationService.PersonalizedContent> filterNodes = contextRequest.getFilters(); - if (filterNodes != null) { - data.setFilteringResults(new HashMap<>()); - for (PersonalizationService.PersonalizedContent personalizedContent : sanitizePersonalizedContentObjects(filterNodes)) { - data.getFilteringResults() - .put(personalizedContent.getId(), personalizationService.filter(profile, session, personalizedContent)); - } - } - - List<PersonalizationService.PersonalizationRequest> personalizations = contextRequest.getPersonalizations(); - if (personalizations != null) { - data.setPersonalizations(new HashMap<>()); - for (PersonalizationService.PersonalizationRequest personalization : sanitizePersonalizations(personalizations)) { - data.getPersonalizations() - .put(personalization.getId(), personalizationService.personalizeList(profile, session, personalization)); - } - } - if (!(profile instanceof Persona)) { data.setTrackedConditions(rulesService.getTrackedConditions(contextRequest.getSource())); } else { diff --git a/rest/src/main/java/org/apache/unomi/utils/Changes.java b/rest/src/main/java/org/apache/unomi/utils/Changes.java index 3ed75a6..b333430 100644 --- a/rest/src/main/java/org/apache/unomi/utils/Changes.java +++ b/rest/src/main/java/org/apache/unomi/utils/Changes.java @@ -43,6 +43,10 @@ public class Changes { return changeType; } + public void setChangeType(int changeType) { + this.changeType = changeType; + } + public int getProcessedItems() { return processedItems; } 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 new file mode 100644 index 0000000..d6435d0 --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/personalization/ControlGroup.java @@ -0,0 +1,68 @@ +/* + * 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 java.util.Date; + +/** + * Represents a personalization control group, stored in a profile and/or a session + */ +public class ControlGroup { + 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 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; + } +} 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 a4ae8f3..eeef772 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,18 +17,20 @@ 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.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.osgi.framework.BundleContext; import org.osgi.framework.ServiceReference; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; public class PersonalizationServiceImpl implements PersonalizationService { @@ -37,6 +39,8 @@ public class PersonalizationServiceImpl implements PersonalizationService { private Map<String, PersonalizationStrategy> personalizationStrategies = new ConcurrentHashMap<>(); + private Random controlGroupRandom = new Random(); + public void setProfileService(ProfileService profileService) { this.profileService = profileService; } @@ -73,19 +77,70 @@ public class PersonalizationServiceImpl implements PersonalizationService { @Override public String bestMatch(Profile profile, Session session, PersonalizationRequest personalizationRequest) { - List<String> sorted = personalizeList(profile,session,personalizationRequest); - if (sorted.size() > 0) { - return sorted.get(0); + PersonalizationResult result = personalizeList(profile,session,personalizationRequest); + if (result.getContentIds().size() > 0) { + return result.getContentIds().get(0); } return null; } @Override - public List<String> personalizeList(Profile profile, Session session, PersonalizationRequest personalizationRequest) { + public PersonalizationResult personalizeList(Profile profile, Session session, PersonalizationRequest personalizationRequest) { PersonalizationStrategy strategy = personalizationStrategies.get(personalizationRequest.getStrategy()); + int changeType = EventService.NO_CHANGE; if (strategy != null) { - return strategy.personalizeList(profile, session, personalizationRequest); + 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"); + } + + boolean profileInControlGroup = false; + Optional<ControlGroup> currentControlGroup; + + List<ControlGroup> controlGroups = null; + if (storeInSession) { + controlGroups = (List<ControlGroup>) session.getProperty("unomiControlGroups"); + } else { + controlGroups = (List<ControlGroup>) profile.getProperty("unomiControlGroups"); + } + 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(); + Double controlGroupPercentage = (Double) controlGroupMap.get("percentage"); + + 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); + if (storeInSession) { + session.setProperty("unomiControlGroups", controlGroups); + changeType = EventService.SESSION_UPDATED; + } else { + profile.setProperty("unomiControlGroups", controlGroups); + 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); + } + } + return new PersonalizationResult(strategy.personalizeList(profile, session, personalizationRequest), changeType); } throw new IllegalArgumentException("Unknown strategy : "+ personalizationRequest.getStrategy()); 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 41ac9b5..809a724 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 @@ -27,6 +27,9 @@ import org.apache.unomi.api.services.ProfileService; import java.util.ArrayList; import java.util.List; +/** + * This strategy will use filters to only keep the contents that match all their associated filters + */ public class FilterPersonalizationStrategy implements PersonalizationStrategy { private ProfileService profileService;
