12
12
# limitations under the License.
13
13
14
14
from __future__ import annotations
15
- from typing import TYPE_CHECKING , NamedTuple , Optional , Sequence
15
+ from typing import TYPE_CHECKING , NamedTuple , Optional , Sequence , List , TypedDict
16
16
17
17
from . import bucketer
18
18
from . import entities
23
23
from .helpers import validator
24
24
from .optimizely_user_context import OptimizelyUserContext , UserAttributes
25
25
from .user_profile import UserProfile , UserProfileService , UserProfileTracker
26
+ from .cmab .cmab_service import DefaultCmabService , CmabDecision
27
+ from optimizely .helpers .enums import Errors
26
28
27
29
if TYPE_CHECKING :
28
30
# prevent circular dependenacy by skipping import at runtime
29
31
from .project_config import ProjectConfig
30
32
from .logger import Logger
31
33
32
34
35
+ class CmabDecisionResult (TypedDict ):
36
+ error : bool
37
+ result : Optional [CmabDecision ]
38
+ reasons : List [str ]
39
+
40
+
33
41
class Decision (NamedTuple ):
34
42
"""Named tuple containing selected experiment, variation and source.
35
43
None if no experiment/variation was selected."""
36
44
experiment : Optional [entities .Experiment ]
37
45
variation : Optional [entities .Variation ]
38
46
source : Optional [str ]
47
+ # cmab_uuid: Optional[str]
39
48
40
49
41
50
class DecisionService :
42
51
""" Class encapsulating all decision related capabilities. """
43
52
44
- def __init__ (self , logger : Logger , user_profile_service : Optional [UserProfileService ]):
53
+ def __init__ (self ,
54
+ logger : Logger ,
55
+ user_profile_service : Optional [UserProfileService ],
56
+ cmab_service : DefaultCmabService ):
45
57
self .bucketer = bucketer .Bucketer ()
46
58
self .logger = logger
47
59
self .user_profile_service = user_profile_service
60
+ self .cmab_service = cmab_service
48
61
49
62
# Map of user IDs to another map of experiments to variations.
50
63
# This contains all the forced variations set by the user
@@ -76,6 +89,48 @@ def _get_bucketing_id(self, user_id: str, attributes: Optional[UserAttributes])
76
89
77
90
return user_id , decide_reasons
78
91
92
+ def _get_decision_for_cmab_experiment (
93
+ self ,
94
+ project_config : ProjectConfig ,
95
+ experiment : entities .Experiment ,
96
+ user_context : OptimizelyUserContext ,
97
+ options : Optional [Sequence [str ]] = None
98
+ ) -> CmabDecisionResult :
99
+ """
100
+ Retrieves a decision for a contextual multi-armed bandit (CMAB) experiment.
101
+
102
+ Args:
103
+ project_config: Instance of ProjectConfig.
104
+ experiment: The experiment object for which the decision is to be made.
105
+ user_context: The user context containing user id and attributes.
106
+ options: Optional sequence of decide options.
107
+
108
+ Returns:
109
+ A dictionary containing:
110
+ - "error": Boolean indicating if there was an error.
111
+ - "result": The CmabDecision result or empty dict if error.
112
+ - "reasons": List of strings with reasons or error messages.
113
+ """
114
+ try :
115
+ options_list = list (options ) if options is not None else []
116
+ cmab_decision = self .cmab_service .get_decision (
117
+ project_config , user_context , experiment .id , options_list
118
+ )
119
+ return {
120
+ "error" : False ,
121
+ "result" : cmab_decision ,
122
+ "reasons" : [],
123
+ }
124
+ except Exception as e :
125
+ error_message = Errors .CMAB_FETCH_FAILED .format (str (e ))
126
+ if self .logger :
127
+ self .logger .error (error_message )
128
+ return {
129
+ "error" : True ,
130
+ "result" : None ,
131
+ "reasons" : [error_message ],
132
+ }
133
+
79
134
def set_forced_variation (
80
135
self , project_config : ProjectConfig , experiment_key : str ,
81
136
user_id : str , variation_key : Optional [str ]
@@ -313,7 +368,7 @@ def get_variation(
313
368
else :
314
369
self .logger .warning ('User profile has invalid format.' )
315
370
316
- # Bucket user and store the new decision
371
+ # Check audience conditions
317
372
audience_conditions = experiment .get_audience_conditions_or_ids ()
318
373
user_meets_audience_conditions , reasons_received = audience_helper .does_user_meet_audience_conditions (
319
374
project_config , audience_conditions ,
@@ -330,8 +385,42 @@ def get_variation(
330
385
# Determine bucketing ID to be used
331
386
bucketing_id , bucketing_id_reasons = self ._get_bucketing_id (user_id , user_context .get_user_attributes ())
332
387
decide_reasons += bucketing_id_reasons
333
- variation , bucket_reasons = self .bucketer .bucket (project_config , experiment , user_id , bucketing_id )
334
- decide_reasons += bucket_reasons
388
+
389
+ if experiment .cmab :
390
+ CMAB_DUMMY_ENTITY_ID = "$"
391
+ # Build the CMAB-specific traffic allocation
392
+ cmab_traffic_allocation = [{
393
+ "entity_id" : CMAB_DUMMY_ENTITY_ID ,
394
+ "end_of_range" : experiment .cmab ['trafficAllocation' ]
395
+ }]
396
+
397
+ # Check if user is in CMAB traffic allocation
398
+ bucketed_entity_id , bucket_reasons = self .bucketer .bucket_to_entity_id (
399
+ bucketing_id , experiment , cmab_traffic_allocation
400
+ )
401
+ decide_reasons += bucket_reasons
402
+ if bucketed_entity_id != CMAB_DUMMY_ENTITY_ID :
403
+ message = f'User "{ user_id } " not in CMAB experiment "{ experiment .key } " due to traffic allocation.'
404
+ self .logger .info (message )
405
+ decide_reasons .append (message )
406
+ return None , decide_reasons
407
+
408
+ # User is in CMAB allocation, proceed to CMAB decision
409
+ decision_variation_value = self ._get_decision_for_cmab_experiment (project_config ,
410
+ experiment ,
411
+ user_context ,
412
+ options )
413
+ decide_reasons += decision_variation_value .get ('reasons' , [])
414
+ cmab_decision = decision_variation_value .get ('result' )
415
+ if not cmab_decision :
416
+ return None , decide_reasons
417
+ variation_id = cmab_decision ['variation_id' ]
418
+ variation = project_config .get_variation_from_id (experiment_key = experiment .key , variation_id = variation_id )
419
+ else :
420
+ # Bucket the user
421
+ variation , bucket_reasons = self .bucketer .bucket (project_config , experiment , user_id , bucketing_id )
422
+ decide_reasons += bucket_reasons
423
+
335
424
if isinstance (variation , entities .Variation ):
336
425
message = f'User "{ user_id } " is in variation "{ variation .key } " of experiment { experiment .key } .'
337
426
self .logger .info (message )
0 commit comments