import json import math import random from otree.api import ( models, widgets, BaseConstants, BaseSubsession, BaseGroup, BasePlayer, ExtraModel, Page, ) doc = 'Organizational Pressure Disclosure and the Customer Service Relationship' # ═══════════════════════════════════════════════════════════ # CIPHER HELPERS # ═══════════════════════════════════════════════════════════ def caesar_encode(text, shift=3): """Encode plain text with a Caesar cipher (default shift=3).""" result = [] for char in text: if char.isalpha(): base = ord('A') if char.isupper() else ord('a') result.append(chr((ord(char) - base + shift) % 26 + base)) else: result.append(char) return ''.join(result) # ═══════════════════════════════════════════════════════════ # SIMULATED PHASE-1 DATA GENERATION # Uses a seeded RNG so results are deterministic across # all Phase-2 sessions (same codebreaker data every time). # ═══════════════════════════════════════════════════════════ _rng = random.Random(42) def _gen_word_times(word_count, sentence_pos): """ Simulate per-word processing times (seconds) for a Phase-1 codebreaker. Uses a Gamma distribution with: - base mean of ~8 s/word - 15% session-fatigue multiplier - slight within-sentence ramp (+3% per word position) - slight across-sentence ramp (+2% per sentence position) """ BASE_MEAN = 8.0 FATIGUE = 1.15 times = [] for i in range(word_count): mean = BASE_MEAN * FATIGUE * (1.0 + i * 0.03) * (1.0 + (sentence_pos - 1) * 0.02) alpha = 8.0 beta = mean / alpha t = _rng.gammavariate(alpha, beta) times.append(round(max(2.0, t), 2)) return times def _gen_word_errors(word_count, sentence_pos): """ Simulate per-word error flags for a Phase-1 codebreaker. Error probability increases with sentence position (cumulative fatigue). - Sentence 1: ~4% per word - Sentence 10: ~26.5% per word """ base_p = 0.04 + (sentence_pos - 1) * 0.025 return [_rng.random() < base_p for _ in range(word_count)] # ═══════════════════════════════════════════════════════════ # SENTENCE DATA # 10 sentences across 3 tiers (High / Moderate / Low), # organised by word count. # ═══════════════════════════════════════════════════════════ _RAW_SENTENCES = [ # (tier, plain_text) # Ordered most-to-least complex so sentence ID 1 = highest complexity. # ── High complexity: 19 words, then 16 words ──────────── ('high', "The board of directors voted unanimously to approve the amended strategic plan " "following months of consultation with regional managers."), # 19 words ('high', "The compliance officer submitted a detailed report outlining potential regulatory " "risks to the executive leadership team."), # 16 words # ── Moderate complexity: 12, 10, 9 words ──────────────── ('moderate', "The board agreed to extend the project by one additional calendar month."), # 12 words ('moderate', "The manager approved the revised schedule after consulting the team."), # 10 words ('moderate', "All employees must submit their updated profiles by Friday."), # 9 words # ── Low complexity: 6, 5, 5, 4, 4 words ───────────────── ('low', "All files were sent on time."), # 6 words ('low', "The report has been filed."), # 5 words ('low', "He approved the final draft."), # 5 words ('low', "The meeting was rescheduled."), # 4 words ('low', "She reviewed the documents."), # 4 words ] SENTENCES = [] for _pos, (_tier, _plain) in enumerate(_RAW_SENTENCES, start=1): _words = _plain.split() _wc = len(_words) _wtimes = _gen_word_times(_wc, _pos) _werrors = _gen_word_errors(_wc, _pos) # Build decoded word list: wrong words are replaced with their cipher form _decoded = [] _error_idx = [] for _wi, (_w, _err) in enumerate(zip(_words, _werrors)): if _err: _decoded.append(caesar_encode(_w, 3)) _error_idx.append(_wi) else: _decoded.append(_w) SENTENCES.append({ 'id': _pos, 'tier': _tier, 'plain': _plain, 'cipher': caesar_encode(_plain), 'plain_words': _words, 'word_count': _wc, 'word_times': _wtimes, 'word_errors': _werrors, 'total_service_time': round(sum(_wtimes), 2), 'error_word_indices': _error_idx, 'decoded_words': _decoded, }) # Sentence IDs are already assigned in descending complexity order, # so sorting by ID ascending gives the correct high→low display order. SENTENCES_BY_COMPLEXITY = sorted(SENTENCES, key=lambda s: s['id']) # Fast lookup by id SENTENCE_MAP = {s['id']: s for s in SENTENCES} # Rush threshold: any submission made when less than this many seconds remain # = minimum total processing time across all sentences (~30–35 s) RUSH_THRESHOLD = min(s['total_service_time'] for s in SENTENCES) # ═══════════════════════════════════════════════════════════ # DISCLOSURE CONSTANTS (treatment condition) # ═══════════════════════════════════════════════════════════ PRIOR_SESSIONS = 2 # sessions the codebreaker worked before this participant SESSIONS_AFTER = 3 # sessions scheduled after PRIOR_SENTENCES = 20 # sentences processed before participant's session def build_schedule(): """ Build the 6-slot assigned shift shown in the MatchingScreen notice. Slot index PRIOR_SESSIONS (0-indexed) is marked as the current session. """ slots = [] for i in range(PRIOR_SESSIONS + 1 + SESSIONS_AFTER): slots.append({ 'n': i + 1, 'is_current': (i == PRIOR_SESSIONS), }) return slots SCHEDULE_SLOTS = build_schedule() # ═══════════════════════════════════════════════════════════ # CONSTANTS # ═══════════════════════════════════════════════════════════ class C(BaseConstants): NAME_IN_URL = 'disclosure_study' PLAYERS_PER_GROUP = None NUM_ROUNDS = 1 SHOW_UP_FEE = 5.00 # dollars EARNINGS_PER_WORD = 0.05 # dollars per correctly decoded word SESSION_DURATION_S = 600 # 10 minutes in seconds MAX_COMPREHENSION_ATTEMPTS = 2 # Comprehension answer keys (integer codes matching choices lists below) COMP_Q1_ANSWER = 3 # $0.05 per word (3rd choice) COMP_Q3_ANSWER = 2 # yes, after first result returned COMP_Q4_ANSWER = 2 # long sentences (most words) # ═══════════════════════════════════════════════════════════ # MODELS # ═══════════════════════════════════════════════════════════ class Subsession(BaseSubsession): pass class Group(BaseGroup): pass class Player(BasePlayer): # ── Task tracking ────────────────────────────────────── total_sentences_submitted = models.IntegerField(initial=0) rush_submissions = models.IntegerField(initial=0) exited_early = models.BooleanField(initial=False) task_earnings = models.FloatField(initial=0.0) total_payment = models.FloatField(initial=0.0) # ── Comprehension ────────────────────────────────────── comprehension_attempts = models.IntegerField(initial=0) comp_q1 = models.IntegerField( label='How much do you earn per correctly decoded word?', choices=[ [1, '$0.01 per word'], [2, '$0.03 per word'], [3, '$0.05 per word'], [4, '$0.10 per word'], ], widget=widgets.RadioSelect, ) comp_q2 = models.IntegerField( label="What happens to the codebreaker's error rate as more sentences are submitted?", choices=[ [1, 'It decreases — the codebreaker warms up'], [2, 'It stays the same throughout'], [3, 'It increases with cumulative workload'], [4, 'It resets after each sentence'], ], widget=widgets.RadioSelect, ) comp_q3 = models.IntegerField( label='Can you leave the session before the 10 minutes are up?', choices=[ [1, 'No — I must stay for the full 10 minutes'], [2, 'Yes — after the first sentence result is returned'], [3, 'Yes — at any point, even before submitting anything'], [4, 'Yes — but I forfeit all my earnings'], ], widget=widgets.RadioSelect, ) comp_q4 = models.IntegerField( label='Which type of sentence offers the highest potential bonus?', choices=[ [1, 'Short sentences — decoded faster'], [2, 'Long sentences — contain more words'], [3, 'All sentences pay the same flat bonus'], [4, 'Medium sentences — best balance of speed and accuracy'], ], widget=widgets.RadioSelect, ) # ── Outtake survey ───────────────────────────────────── satisfaction = models.IntegerField( label='Overall, how satisfied were you with the service provided by your codebreaker today?', choices=list(range(1, 8)), widget=widgets.RadioSelectHorizontal, ) likelihood_return = models.IntegerField( label='If you were to participate in a future session, how likely would you be to request the same codebreaker?', choices=list(range(1, 8)), widget=widgets.RadioSelectHorizontal, ) # ── Attribution & manipulation check ────────────────── attr_personal_responsibility = models.IntegerField( label='If your codebreaker made any errors, how much do you think those were their own fault?', choices=list(range(1, 8)), widget=widgets.RadioSelectHorizontal, ) attr_working_conditions = models.IntegerField( label='How much do you think the situation your codebreaker was working in contributed to any errors?', choices=list(range(1, 8)), widget=widgets.RadioSelectHorizontal, ) attr_schedule_control = models.IntegerField( label='How much control do you think your codebreaker had over how their workday was scheduled?', choices=list(range(1, 8)), widget=widgets.RadioSelectHorizontal, ) attr_org_pressure = models.IntegerField( label="How demanding do you think your codebreaker's workload was today?", choices=list(range(1, 8)), widget=widgets.RadioSelectHorizontal, ) manip_check_binary = models.StringField( label="Before your session began, did the Research Team share any information about your codebreaker's situation?", choices=[['yes', 'Yes'], ['no', 'No'], ['unsure', "I don't recall"]], widget=widgets.RadioSelect, ) manip_check_text = models.LongStringField( label='In your own words, describe anything that was communicated before the session. (Write "nothing" if nothing was shared.)', blank=True, ) empathy = models.IntegerField( label='While submitting sentences, how often did you find yourself thinking about what your codebreaker was going through?', choices=list(range(1, 8)), widget=widgets.RadioSelectHorizontal, ) fairness = models.IntegerField( label='How fair do you think it was that your codebreaker was assigned to work the way they did today?', choices=list(range(1, 8)), widget=widgets.RadioSelectHorizontal, ) # ── Demographics ─────────────────────────────────────── age = models.IntegerField(label='What is your age?', min=18, max=100) gender = models.StringField( label='What is your gender?', choices=[ ['male', 'Male'], ['female', 'Female'], ['non_binary', 'Non-binary / Gender non-conforming'], ['prefer_not_to_say', 'Prefer not to say'], ], widget=widgets.RadioSelect, ) year_in_school = models.IntegerField( label='What is your year in school?', choices=[ [1, 'Freshman (1st year)'], [2, 'Sophomore (2nd year)'], [3, 'Junior (3rd year)'], [4, 'Senior (4th year)'], [5, 'Graduate / Professional student'], ], widget=widgets.RadioSelect, ) major = models.StringField(label='What is your field of study or major?') native_english = models.BooleanField( label='Is English your native language?', choices=[[True, 'Yes'], [False, 'No']], widget=widgets.RadioSelect, ) prior_experience = models.StringField( label='Have you participated in a study involving customer service or codebreaking tasks before?', choices=[['yes', 'Yes'], ['no', 'No'], ['unsure', 'Not sure']], widget=widgets.RadioSelect, ) # ─── ExtraModel: one row per sentence submission ─────────── class SentenceSubmission(ExtraModel): player = models.Link(Player) sentence_id = models.IntegerField() submission_order = models.IntegerField() word_count = models.IntegerField() # number of words in sentence correct_words = models.IntegerField() # words decoded without error num_word_errors = models.IntegerField() # words decoded incorrectly earnings = models.FloatField() # dollars this submission is_rush = models.BooleanField() time_remaining_at_submission = models.IntegerField() word_times_json = models.LongStringField(initial='[]') # list[float] seconds/word error_word_indices_json = models.LongStringField(initial='[]') # list[int] decoded_words_json = models.LongStringField(initial='[]') # list[str] # ═══════════════════════════════════════════════════════════ # HOOK FUNCTIONS # ═══════════════════════════════════════════════════════════ def creating_session(subsession: Subsession): for player in subsession.get_players(): player.participant.treatment = random.choice(['control', 'treatment']) # ═══════════════════════════════════════════════════════════ # LIVE METHOD (MainTask page) # ═══════════════════════════════════════════════════════════ def live_method(player: Player, data: dict): msg_type = data.get('type') # ── Sentence submission ──────────────────────────────── if msg_type == 'submit': sentence_id = int(data['sentence_id']) time_remaining = int(data.get('time_remaining', 0)) # Guard: already submitted? if SentenceSubmission.filter(player=player, sentence_id=sentence_id): return {player.id_in_group: {'type': 'already_submitted'}} sentence = SENTENCE_MAP.get(sentence_id) if not sentence: return {player.id_in_group: {'type': 'error', 'message': 'Invalid sentence id'}} # Increment submission counter player.total_sentences_submitted += 1 order = player.total_sentences_submitted # Rush: submitted when less time remains than the shortest possible processing job is_rush = (time_remaining < RUSH_THRESHOLD) if is_rush: player.rush_submissions += 1 # Pre-generated Phase-1 error data (deterministic per sentence) correct_words = sentence['word_count'] - len(sentence['error_word_indices']) earnings = round(correct_words * C.EARNINGS_PER_WORD, 2) # Persist submission SentenceSubmission.create( player=player, sentence_id=sentence_id, submission_order=order, word_count=sentence['word_count'], correct_words=correct_words, num_word_errors=len(sentence['error_word_indices']), earnings=earnings, is_rush=is_rush, time_remaining_at_submission=time_remaining, word_times_json=json.dumps(sentence['word_times']), error_word_indices_json=json.dumps(sentence['error_word_indices']), decoded_words_json=json.dumps(sentence['decoded_words']), ) player.task_earnings = round(player.task_earnings + earnings, 2) # Acknowledge: return word_times so the client can drive the progress bar return {player.id_in_group: { 'type': 'ack', 'sentence_id': sentence_id, 'word_times': sentence['word_times'], }} # ── Fetch result after progress bar completes ────────── elif msg_type == 'get_result': sentence_id = int(data['sentence_id']) subs = SentenceSubmission.filter(player=player, sentence_id=sentence_id) if not subs: return {player.id_in_group: {'type': 'error', 'message': 'Submission not found'}} sub = subs[0] return {player.id_in_group: { 'type': 'result', 'sentence_id': sentence_id, 'decoded_words': json.loads(sub.decoded_words_json), 'error_word_indices': json.loads(sub.error_word_indices_json), 'correct_words': sub.correct_words, 'num_word_errors': sub.num_word_errors, 'earnings': sub.earnings, 'total_earnings': player.task_earnings, }} # ── Early exit ───────────────────────────────────────── elif msg_type == 'exit_early': player.exited_early = True return {player.id_in_group: {'type': 'exit_confirmed'}} # ═══════════════════════════════════════════════════════════ # PAGES # ═══════════════════════════════════════════════════════════ class Consent(Page): @staticmethod def vars_for_template(player: Player): return dict( show_up_fee=C.SHOW_UP_FEE, earnings_per_word=C.EARNINGS_PER_WORD, ) class Instructions(Page): @staticmethod def vars_for_template(player: Player): return dict(earnings_per_word=C.EARNINGS_PER_WORD) class ComprehensionCheck(Page): form_model = 'player' form_fields = ['comp_q1', 'comp_q3', 'comp_q4'] # comp_q2 (error rate) removed @staticmethod def vars_for_template(player: Player): return dict( attempts_used=player.comprehension_attempts, max_attempts=C.MAX_COMPREHENSION_ATTEMPTS, ) @staticmethod def error_message(player: Player, values): player.comprehension_attempts += 1 errors = [] if values['comp_q1'] != C.COMP_Q1_ANSWER: errors.append('Q1: You earn $0.05 per correctly decoded word.') if values['comp_q3'] != C.COMP_Q3_ANSWER: errors.append('Q2: You can exit after the first sentence result is returned.') if values['comp_q4'] != C.COMP_Q4_ANSWER: errors.append('Q3: Longer sentences contain more words and offer higher potential earnings.') if errors: remaining = C.MAX_COMPREHENSION_ATTEMPTS - player.comprehension_attempts suffix = ( f' You have {remaining} attempt(s) remaining.' if remaining > 0 else ' Maximum attempts reached — please contact the experimenter.' ) return ' | '.join(errors) + suffix class ProviderIntro(Page): @staticmethod def vars_for_template(player: Player): return dict(earnings_per_word=C.EARNINGS_PER_WORD) class MatchingScreen(Page): @staticmethod def vars_for_template(player: Player): is_treatment = (getattr(player.participant, 'treatment', 'control') == 'treatment') return dict( is_treatment=is_treatment, prior_sentences=PRIOR_SENTENCES, prior_sessions=PRIOR_SESSIONS, sessions_after=SESSIONS_AFTER, schedule_slots=SCHEDULE_SLOTS if is_treatment else [], ) class MainTask(Page): live_method = live_method @staticmethod def vars_for_template(player: Player): return dict(is_treatment=(getattr(player.participant, 'treatment', 'control') == 'treatment')) @staticmethod def js_vars(player: Player): # Only send fields the client actually needs sentences_js = [ { 'id': s['id'], 'tier': s['tier'], 'word_count': s['word_count'], 'cipher': s['cipher'], 'total_service_time': s['total_service_time'], } for s in SENTENCES_BY_COMPLEXITY ] return dict( sentences=sentences_js, is_treatment=(getattr(player.participant, 'treatment', 'control') == 'treatment'), session_duration=C.SESSION_DURATION_S, earnings_per_word=C.EARNINGS_PER_WORD, rush_threshold=RUSH_THRESHOLD, ) @staticmethod def before_next_page(player: Player, timeout_happened): player.total_payment = round(C.SHOW_UP_FEE + player.task_earnings, 2) class PayoffSummary(Page): @staticmethod def vars_for_template(player: Player): subs = sorted( SentenceSubmission.filter(player=player), key=lambda s: s.submission_order, ) return dict( submissions=subs, task_earnings=player.task_earnings, show_up_fee=C.SHOW_UP_FEE, total_payment=player.total_payment, ) class OutakeSurvey(Page): form_model = 'player' form_fields = ['satisfaction', 'likelihood_return'] class AttributionCheck(Page): form_model = 'player' form_fields = [ 'attr_personal_responsibility', 'attr_working_conditions', 'attr_schedule_control', 'attr_org_pressure', 'manip_check_binary', 'manip_check_text', 'empathy', 'fairness', ] class Demographics(Page): form_model = 'player' form_fields = ['age', 'gender', 'year_in_school', 'major', 'native_english', 'prior_experience'] class ThankYou(Page): @staticmethod def vars_for_template(player: Player): return dict( total_payment=player.total_payment, task_earnings=player.task_earnings, show_up_fee=C.SHOW_UP_FEE, ) page_sequence = [ Consent, Instructions, ComprehensionCheck, ProviderIntro, MatchingScreen, MainTask, PayoffSummary, OutakeSurvey, AttributionCheck, Demographics, ThankYou, ] # ═══════════════════════════════════════════════════════════ # CUSTOM DATA EXPORT # ═══════════════════════════════════════════════════════════ # NOTE: For oTree 5.x compatibility, add the following to settings.py: # PARTICIPANT_FIELDS = ['treatment'] # This ensures the 'treatment' participant attribute persists across pages. def custom_export(players): header = [ # ── Participant identifiers ──────────────────────── 'participant_code', 'treatment', # ── Task-level summary ───────────────────────────── 'total_sentences_submitted', 'rush_submissions', 'any_rush_submission', # 1 if at least one rush submission, else 0 'exited_early', 'task_earnings', 'total_payment', # ── Per-submission (one row per sentence) ────────── 'submission_order', 'sentence_id', 'sentence_tier', # high / moderate / low 'word_count', 'correct_words', 'num_word_errors', 'earnings_per_sentence', 'is_rush', 'time_remaining_at_submission', 'decoded_sentence', # full decoded text (plain or error words) # ── Outtake survey ───────────────────────────────── 'satisfaction', 'likelihood_return', # ── Attribution & manipulation check ────────────── 'attr_personal_responsibility', 'attr_working_conditions', 'attr_schedule_control', 'attr_org_pressure', 'manip_check_binary', 'manip_check_text', 'empathy', 'fairness', # ── Demographics ─────────────────────────────────── 'age', 'gender', 'year_in_school', 'major', 'native_english', 'prior_experience', 'comprehension_attempts', ] yield header for p in players: any_rush = 1 if p.rush_submissions > 0 else 0 base = [ p.participant.code, getattr(p.participant, 'treatment', 'unknown'), p.total_sentences_submitted, p.rush_submissions, any_rush, p.exited_early, p.task_earnings, p.total_payment, ] survey = [ p.field_maybe_none('satisfaction'), p.field_maybe_none('likelihood_return'), p.field_maybe_none('attr_personal_responsibility'), p.field_maybe_none('attr_working_conditions'), p.field_maybe_none('attr_schedule_control'), p.field_maybe_none('attr_org_pressure'), p.field_maybe_none('manip_check_binary'), p.field_maybe_none('manip_check_text'), p.field_maybe_none('empathy'), p.field_maybe_none('fairness'), p.field_maybe_none('age'), p.field_maybe_none('gender'), p.field_maybe_none('year_in_school'), p.field_maybe_none('major'), p.field_maybe_none('native_english'), p.field_maybe_none('prior_experience'), p.comprehension_attempts, ] subs = sorted(SentenceSubmission.filter(player=p), key=lambda s: s.submission_order) if subs: for sub in subs: sent = SENTENCE_MAP.get(sub.sentence_id, {}) tier = sent.get('tier', '') dec_words = json.loads(sub.decoded_words_json) if sub.decoded_words_json else [] decoded_str = ' '.join(dec_words) yield base + [ sub.submission_order, sub.sentence_id, tier, sub.word_count, sub.correct_words, sub.num_word_errors, sub.earnings, sub.is_rush, sub.time_remaining_at_submission, decoded_str, ] + survey else: # Participant who submitted nothing yield base + [''] * 10 + survey