1212# limitations under the License.
1313
1414from __future__ import annotations
15- from typing import TYPE_CHECKING , NamedTuple , Optional , Sequence
15+ from typing import TYPE_CHECKING , NamedTuple , Optional , Sequence , List , TypedDict
1616
1717from . import bucketer
1818from . import entities
2323from .helpers import validator
2424from .optimizely_user_context import OptimizelyUserContext , UserAttributes
2525from .user_profile import UserProfile , UserProfileService , UserProfileTracker
26+ from .cmab .cmab_service import DefaultCmabService , CmabDecision
27+ from optimizely .helpers .enums import Errors
2628
2729if TYPE_CHECKING :
2830 # prevent circular dependenacy by skipping import at runtime
2931 from .project_config import ProjectConfig
3032 from .logger import Logger
3133
3234
35+ class CmabDecisionResult (TypedDict ):
36+ error : bool
37+ result : Optional [CmabDecision ]
38+ reasons : List [str ]
39+
40+
3341class Decision (NamedTuple ):
3442 """Named tuple containing selected experiment, variation and source.
3543 None if no experiment/variation was selected."""
3644 experiment : Optional [entities .Experiment ]
3745 variation : Optional [entities .Variation ]
3846 source : Optional [str ]
47+ # cmab_uuid: Optional[str]
3948
4049
4150class DecisionService :
4251 """ Class encapsulating all decision related capabilities. """
4352
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 ):
4557 self .bucketer = bucketer .Bucketer ()
4658 self .logger = logger
4759 self .user_profile_service = user_profile_service
60+ self .cmab_service = cmab_service
4861
4962 # Map of user IDs to another map of experiments to variations.
5063 # 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])
7689
7790 return user_id , decide_reasons
7891
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+
79134 def set_forced_variation (
80135 self , project_config : ProjectConfig , experiment_key : str ,
81136 user_id : str , variation_key : Optional [str ]
@@ -313,7 +368,7 @@ def get_variation(
313368 else :
314369 self .logger .warning ('User profile has invalid format.' )
315370
316- # Bucket user and store the new decision
371+ # Check audience conditions
317372 audience_conditions = experiment .get_audience_conditions_or_ids ()
318373 user_meets_audience_conditions , reasons_received = audience_helper .does_user_meet_audience_conditions (
319374 project_config , audience_conditions ,
@@ -330,8 +385,42 @@ def get_variation(
330385 # Determine bucketing ID to be used
331386 bucketing_id , bucketing_id_reasons = self ._get_bucketing_id (user_id , user_context .get_user_attributes ())
332387 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+
335424 if isinstance (variation , entities .Variation ):
336425 message = f'User "{ user_id } " is in variation "{ variation .key } " of experiment { experiment .key } .'
337426 self .logger .info (message )
0 commit comments