from otree.api import * import asyncio import csv import io import os import random import re import boto3 import httpx from botocore.config import Config as BotoConfig from pathlib import Path from dotenv import load_dotenv load_dotenv(Path.home() / ".config" / "otree" / ".env") doc = """ Shipping voice-decision task — conceptual replication of the Qualtrics study 'Pretest Confidence Shipping - Preference - inComplete information'. Flow (single scenario): Intro (task + $2 bonus) -> ComprehensionCheck -> ChoiceSetIntro (setup + a blank delivery table) -> ChoiceSet (the known delivery times revealed, fixed for the condition; SPEAK the choice, confirm/correct the coded company) -> BDMDecision (a slider: the smallest guaranteed bonus you'd accept instead of the risky $2 bonus; a real BDM draw then decides whether the guaranteed or the risky bonus is paid — the page explains the mechanism) -> Results Delivery-time data and the initial visibility masks are loaded live from a Google Sheet at server startup (see SHEET_ID / *_GID below), mirroring the loader in studies/19-dnd/software/dealnodeal/__init__.py. If the sheet cannot be read the app falls back to built-in placeholder data and prints a warning. """ # --------------------------------------------------------------------------- # Google Sheet loader # --------------------------------------------------------------------------- # Sheet: "voice-shipping-study". One tab, keyed by (condition, company): # # condition,company,d1,d2,d3,d4,d5,d6,d7,d8,v1,v2,v3,v4,v5,v6,v7,v8 # # d1..d8 are the 8 most-recent delivery times (business days). # v1..v8 are TRUE/FALSE: whether that delivery time is visible from the # start (fixed per condition — see N_VISIBLE_INITIAL — no paid reveal). # # condition is one of: "low confidence", "high confidence" — this encodes how # much delivery history is visible (1 of 8 vs 7 of 8), not a consistency # framing. # company is one of: "A", "B" # # The tab is addressed by gid (stable across renames), not by name. The sheet # must be link-shared ("Anyone with the link -> Viewer") for the CSV export # to work. SHEET_ID = '1M4MoIYUXxWTaRwMmfvSmtrmQAswe_EhSA6aJSZIsUC4' SHEET_GID = 1824245736 # the single condition/company/d1-8/v1-8 tab CONDITIONS = ['low confidence', 'high confidence'] COMPANIES = ['A', 'B'] N_DELIVERIES = 8 # How many of the 8 recent delivery times are visible from the start, per # condition (fixed for the whole study — no paid reveal mechanic). N_VISIBLE_INITIAL = {'low confidence': 1, 'high confidence': 7} # Built-in placeholder data. Used only when the Google Sheet cannot be read or # is still empty, so the app boots for development. Company B is the faster one # (lower mean) in both conditions here; replace with real values via the sheet. _FALLBACK_TIMESERIES = { ('low confidence', 'A'): [2, 7, 1, 6, 3, 7, 2, 5], ('low confidence', 'B'): [1, 6, 2, 5, 1, 6, 2, 4], ('high confidence', 'A'): [5, 5, 5, 4, 5, 5, 5, 5], ('high confidence', 'B'): [4, 4, 4, 4, 3, 4, 4, 4], } _FALLBACK_MISSING_INFO = { ('low confidence', 'A'): [True, False, False, False, False, False, False, False], ('low confidence', 'B'): [False, True, False, False, False, False, False, False], ('high confidence', 'A'): [True, True, True, False, True, True, True, True], ('high confidence', 'B'): [True, True, True, True, True, False, True, True], } def _sheet_csv(gid): url = ( f'https://docs.google.com/spreadsheets/d/{SHEET_ID}' f'/gviz/tq?tqx=out:csv&gid={gid}' ) r = httpx.get(url, timeout=15, follow_redirects=True) r.raise_for_status() rows = list(csv.DictReader(io.StringIO(r.text))) if not rows: raise RuntimeError(f"Google Sheet tab gid={gid} is empty.") return rows def _norm(s): return (s or '').strip() def _load_sheet_data(): """Read the single condition/company/d1-8/v1-8 tab, returning (timeseries, missing_info) dicts keyed by (condition, company).""" timeseries, missing_info = {}, {} for row in _sheet_csv(SHEET_GID): cond = _norm(row.get('condition')).lower() comp = _norm(row.get('company')).upper() if cond not in CONDITIONS or comp not in COMPANIES: raise RuntimeError( f"[decision] unexpected condition/company " f"{row.get('condition')!r}/{row.get('company')!r}. " f"condition must be one of {CONDITIONS}, company one of {COMPANIES}." ) try: days = [int(float(_norm(row[f'd{i}']))) for i in range(1, N_DELIVERIES + 1)] except (KeyError, ValueError) as e: raise RuntimeError( f"[decision] row {cond}/{comp}: need numeric d1..d{N_DELIVERIES} ({e})." ) try: vis = [_norm(row[f'v{i}']).lower() in ('true', '1', 'yes', 'x') for i in range(1, N_DELIVERIES + 1)] except KeyError as e: raise RuntimeError( f"[decision] row {cond}/{comp}: need v1..v{N_DELIVERIES} ({e})." ) expected = N_VISIBLE_INITIAL[cond] if sum(vis) != expected: raise RuntimeError( f"[decision] row {cond}/{comp}: exactly {expected} of v1..v{N_DELIVERIES} " f"must be TRUE for this condition (got {sum(vis)})." ) timeseries[(cond, comp)] = days missing_info[(cond, comp)] = vis for cond in CONDITIONS: for comp in COMPANIES: if (cond, comp) not in timeseries: raise RuntimeError( f"[decision] missing row for condition {cond!r}, company {comp!r}." ) return timeseries, missing_info # --------------------------------------------------------------------------- class C(BaseConstants): NAME_IN_URL = 'decision' PLAYERS_PER_GROUP = None NUM_ROUNDS = 1 BONUS_CENTS = 200 # $2.00 for choosing the faster company # BDM valuation: smallest guaranteed bonus accepted instead of the risky # bonus, elicited in $0.10 steps from $0.00 to $2.00 (21 options), matching # the source qsf's discrete answer list. BDM_STEP_CENTS = 10 BDM_MIN_CENTS = 0 BDM_MAX_CENTS = BONUS_CENTS # On the decision screens, wait this long before the recorder pill appears # and recording starts, so participants can read the delivery table first. RECORD_DELAY_MS = 4000 def _load_or_fallback(): try: ts, mi = _load_sheet_data() print(f"[decision] loaded delivery data from Google Sheet {SHEET_ID}") return ts, mi except Exception as e: print( f"[decision] WARNING: could not load delivery data from the Google " f"Sheet ({e}). Falling back to built-in placeholder data — fill the " f"sheet tab (gid={SHEET_GID}) before running the real study." ) return dict(_FALLBACK_TIMESERIES), dict(_FALLBACK_MISSING_INFO) TIMESERIES, MISSING_INFO = _load_or_fallback() class Subsession(BaseSubsession): pass class Group(BaseGroup): pass class Player(BasePlayer): condition = models.StringField() swap_ab = models.BooleanField() # displayed A/B <-> sheet A/B counterbalance faster_company = models.StringField() # displayed company with the lower mean delivery time comprehension_bonus = models.StringField(blank=True) # The A/B choice is spoken, not clicked. After the recording is uploaded, the # server transcribes `_choice.wav` (OpenAI Whisper) and codes the spoken # choice as 'A' / 'B' — see _transcribe_and_code, called from live_method. # The participant then confirms that coded choice or picks the other # company (no re-record); the confirmed value lands in `choice`. choice_record_start_ts = models.StringField(blank=True) choice_voice_onset_ts = models.StringField(blank=True) choice_upload_success = models.BooleanField(blank=True) choice_transcript = models.LongStringField(blank=True) choice_coded = models.StringField(blank=True) # 'A' / 'B' from transcription choice_method = models.StringField(blank=True) # regex / gpt / unclear / no_audio choice = models.StringField(blank=True) # 'A' / 'B' the participant confirmed choice_confirmed = models.BooleanField(blank=True, initial=False) # BDM valuation: smallest guaranteed bonus (in cents) accepted instead of # the risky $2 bonus. Picked by clicking one of a fixed set of amounts — # the click itself is the answer, no transcription/coding needed. bdm_value = models.IntegerField(blank=True) # cents # The BDM mechanism: a random guaranteed price is drawn and compared to # bdm_value to decide which bonus is actually paid. bdm_draw_cents = models.IntegerField(blank=True) mechanism_branch = models.StringField(blank=True) # 'guaranteed' / 'risky' bonus_cents = models.IntegerField(blank=True) # payoff, from the BDM mechanism ts_page_load = models.StringField(blank=True) # --------------------------------------------------------------------------- # Session setup # --------------------------------------------------------------------------- def creating_session(subsession: Subsession): for player in subsession.get_players(): # Between-subjects info-completeness condition, even allocation by seat. cond = CONDITIONS[player.participant.id_in_session % len(CONDITIONS)] player.condition = cond rng = random.Random(player.participant.id_in_session) swap = rng.random() < 0.5 player.swap_ab = swap shipping = {} for shown in COMPANIES: sheet_co = _sheet_company(shown, swap) days = list(TIMESERIES[(cond, sheet_co)]) vis = MISSING_INFO[(cond, sheet_co)] visible_idx = [i for i, v in enumerate(vis) if v] shipping[shown] = dict( days=days, visible_idx=visible_idx, mean=sum(days) / len(days), ) player.faster_company = ( 'A' if shipping['A']['mean'] <= shipping['B']['mean'] else 'B' ) player.participant.vars['shipping'] = shipping def _sheet_company(shown_company, swap): """Map a displayed company label ('A'/'B') back to the sheet company.""" if not swap: return shown_company return 'B' if shown_company == 'A' else 'A' # --------------------------------------------------------------------------- # Delivery-history views # --------------------------------------------------------------------------- def _cell(days, i, shown, blind): if blind: return '', 'blank' if i not in shown: return '?', 'unknown' v = days[i] return (f"{v} day" if v == 1 else f"{v} days"), '' def _table_rows(player: Player, blind=False): """Rows for the delivery-history table. blind=True hides every value ('?').""" ship = player.participant.vars['shipping'] shown_a = set(ship['A']['visible_idx']) shown_b = set(ship['B']['visible_idx']) rows = [] for i in range(N_DELIVERIES): a_display, a_class = _cell(ship['A']['days'], i, shown_a, blind) b_display, b_class = _cell(ship['B']['days'], i, shown_b, blind) rows.append(dict( n=i + 1, a_display=a_display, a_class=a_class, b_display=b_display, b_class=b_class, )) return rows def _decision_js_vars(player: Player): """js_vars for ChoiceSet: just the recorder timing.""" return dict(record_delay_ms=C.RECORD_DELAY_MS) # --------------------------------------------------------------------------- # Server-side transcription + coding of the spoken choice / BDM value # --------------------------------------------------------------------------- _CHOICE_A_RE = re.compile(r'\bcompany\s*a\b|\boption\s*a\b|\b(choose|pick|go with|going with)\s+a\b', re.I) _CHOICE_B_RE = re.compile(r'\bcompany\s*b\b|\boption\s*b\b|\b(choose|pick|go with|going with)\s+b\b', re.I) def _openai_key(): return os.environ.get('WHISPER_KEY') or os.environ.get('OPENAI_API_KEY') def _classify_choice_regex(text): a, b = bool(_CHOICE_A_RE.search(text)), bool(_CHOICE_B_RE.search(text)) if a and not b: return 'A' if b and not a: return 'B' return None def _classify_choice_gpt(text): key = _openai_key() if not key or not text.strip(): return None try: r = httpx.post( 'https://api.openai.com/v1/chat/completions', headers={'Authorization': f'Bearer {key}'}, json={ 'model': 'gpt-4o-mini', 'temperature': 0, 'max_tokens': 3, 'messages': [ {'role': 'system', 'content': ( 'A participant says which of two shipping companies, "Company A" or ' '"Company B", they would choose for their online order. ' 'Reply with only "A", "B", or "unclear".')}, {'role': 'user', 'content': text}, ], }, timeout=20, ) r.raise_for_status() ans = r.json()['choices'][0]['message']['content'].strip().upper() return ans if ans in ('A', 'B') else None except Exception as e: print(f'[decision] choice GPT error: {e}') return None def _transcribe_s3_wav(key): """Download an S3 object and return its Whisper transcript ('' on any failure).""" api_key = _openai_key() if not api_key: print('[decision] no OpenAI key (WHISPER_KEY / OPENAI_API_KEY) — skipping transcription') return '' try: s3 = boto3.client( 's3', aws_access_key_id=os.environ.get('S3_ACCESS_KEY'), aws_secret_access_key=os.environ.get('S3_SECRET_KEY'), region_name='eu-north-1', config=BotoConfig(signature_version='s3v4'), ) audio = s3.get_object(Bucket='ethz-otree-whisper', Key=key)['Body'].read() r = httpx.post( 'https://api.openai.com/v1/audio/transcriptions', headers={'Authorization': f'Bearer {api_key}'}, files={'file': (key, audio, 'audio/wav')}, data={'model': 'whisper-1'}, timeout=45, ) r.raise_for_status() return (r.json().get('text') or '').replace('\n', ' ').replace('\r', ' ').strip() except Exception as e: print(f'[decision] transcription error for {key}: {e}') return '' def _transcribe_and_code(session_code, participant_code, phase): """Return (transcript, choice, method) for the spoken A/B choice. method is 'regex' / 'gpt' when a choice was found, 'unclear' when a transcript exists but no A/B could be extracted, and 'no_audio' when there is no usable transcript. """ key = f'{session_code}_{participant_code}_{phase}.wav' text = _transcribe_s3_wav(key) if not text: return '', '', 'no_audio' choice = _classify_choice_regex(text) if choice: return text, choice, 'regex' choice = _classify_choice_gpt(text) if choice: return text, choice, 'gpt' return text, '', 'unclear' # --------------------------------------------------------------------------- # Pages # --------------------------------------------------------------------------- class Intro(Page): # Task + bonus, matching the source qsf's single "Intro Task" block. pass class ComprehensionCheck(Page): form_model = 'player' form_fields = ['comprehension_bonus'] class ChoiceSetIntro(Page): # The setup and a fully blank table. Clicking through reveals the known # values (on ChoiceSet) and starts the recording. @staticmethod def vars_for_template(player: Player): return dict( rows=_table_rows(player, blind=True), n_visible=N_VISIBLE_INITIAL[player.condition], n_total=N_DELIVERIES, ) class ChoiceSet(Page): form_model = 'player' form_fields = [ 'choice', 'choice_confirmed', 'choice_record_start_ts', 'choice_voice_onset_ts', 'ts_page_load', ] @staticmethod def vars_for_template(player: Player): return dict( rows=_table_rows(player), n_visible=N_VISIBLE_INITIAL[player.condition], n_total=N_DELIVERIES, ) @staticmethod def js_vars(player: Player): return _decision_js_vars(player) @staticmethod async def live_method(player: Player, data): filename = f"{player.session.code}_{player.participant.code}_choice.wav" action = data.get('action') loop = asyncio.get_running_loop() if action == 'get_upload_url': try: s3 = boto3.client( 's3', aws_access_key_id=os.environ.get('S3_ACCESS_KEY'), aws_secret_access_key=os.environ.get('S3_SECRET_KEY'), region_name='eu-north-1', config=BotoConfig(signature_version='s3v4'), ) url = await loop.run_in_executor( None, lambda: s3.generate_presigned_url( 'put_object', Params={'Bucket': 'ethz-otree-whisper', 'Key': filename}, ExpiresIn=300, ), ) yield {player.id_in_group: {'upload_url': url}} except Exception as e: print(f"S3 presign error: {e}") yield {player.id_in_group: {'upload_url': None}} elif action == 'upload_done': success = bool(data.get('success')) player.choice_upload_success = success transcript, choice = '', '' if success: transcript, choice, method = await loop.run_in_executor( None, _transcribe_and_code, player.session.code, player.participant.code, 'choice', ) player.choice_transcript = transcript player.choice_coded = choice player.choice_method = method yield {player.id_in_group: { 'uploaded': success, 'transcript': transcript, 'choice': choice, }} class BDMDecision(Page): # The BDM valuation is picked with a slider, not spoken/recorded. form_model = 'player' form_fields = ['bdm_value'] @staticmethod def vars_for_template(player: Player): return dict( choice=player.field_maybe_none('choice'), bdm_min_cents=C.BDM_MIN_CENTS, bdm_max_cents=C.BDM_MAX_CENTS, bdm_step_cents=C.BDM_STEP_CENTS, bdm_default_cents=(C.BDM_MIN_CENTS + C.BDM_MAX_CENTS) // 2, bdm_min_label=_usd(C.BDM_MIN_CENTS), bdm_max_label=_usd(C.BDM_MAX_CENTS), ) @staticmethod def before_next_page(player: Player, timeout_happened): # BDM mechanism: draw a random guaranteed price; if it's at least the # participant's stated minimum, pay that guaranteed price, otherwise # pay the risky bonus based on the confirmed choice. Left unset (blank) # for manual review if either the choice or the BDM value could not be # coded. choice = player.field_maybe_none('choice') bdm_value = player.field_maybe_none('bdm_value') if choice in ('A', 'B') and bdm_value is not None: draw = random.choice(range(C.BDM_MIN_CENTS, C.BDM_MAX_CENTS + 1, C.BDM_STEP_CENTS)) player.bdm_draw_cents = draw if draw >= bdm_value: player.mechanism_branch = 'guaranteed' player.bonus_cents = draw else: player.mechanism_branch = 'risky' player.bonus_cents = C.BONUS_CENTS if choice == player.faster_company else 0 player.payoff = cu(player.bonus_cents / 100) # Expose the realised bonus to the outro app's Debriefing page. player.participant.vars['bonus_cents'] = player.field_maybe_none('bonus_cents') def _usd(cents): return '${:,.2f}'.format((cents or 0) / 100) class Results(Page): @staticmethod def is_displayed(player: Player): return player.round_number == C.NUM_ROUNDS @staticmethod def vars_for_template(player: Player): choice = player.field_maybe_none('choice') coded = choice in ('A', 'B') bdm_value = player.field_maybe_none('bdm_value') draw = player.field_maybe_none('bdm_draw_cents') branch = player.field_maybe_none('mechanism_branch') won = coded and choice == player.faster_company return dict( coded=coded, choice=choice, faster_company=player.faster_company, won=won, resolved=branch in ('guaranteed', 'risky'), branch=branch, bdm_value=_usd(bdm_value) if bdm_value is not None else None, draw=_usd(draw) if draw is not None else None, net_bonus=_usd(player.field_maybe_none('bonus_cents')), ) page_sequence = [ Intro, ComprehensionCheck, ChoiceSetIntro, ChoiceSet, BDMDecision, Results, ] # --------------------------------------------------------------------------- # Custom export — one row per participant, recordings + outro denormalised # --------------------------------------------------------------------------- COMPREHENSION_BONUS_CORRECT = '2.00' def _maybe(obj, name): try: return obj.field_maybe_none(name) except Exception: try: return getattr(obj, name) except Exception: return None def _latency_ms(start_ts, onset_ts): try: return int(onset_ts) - int(start_ts) except (TypeError, ValueError): return '' def _app_of(player): return type(player).__module__.split('.')[0] def _choice_correct(dp): c = _maybe(dp, 'choice') fast = _maybe(dp, 'faster_company') if c in ('A', 'B') and fast in ('A', 'B'): return c == fast return '' _EXPORT_COLUMNS = [ 'session_code', 'participant_code', 'participant_label', 'participant_id_in_session', 'participant_finished', 'condition', 'swap_ab', 'faster_company', 'company_a_mean_days', 'company_b_mean_days', 'company_a_visible_idx', 'company_b_visible_idx', 'comprehension_bonus', 'comprehension_bonus_correct', 'choice_onset_latency_ms', 'choice_upload_success', 'choice_coded', 'choice_method', 'choice_transcript', 'choice', 'choice_confirmed', 'choice_correct', 'bdm_value', 'bdm_draw_cents', 'mechanism_branch', 'bonus_cents', 'ts_page_load', 'baseline_onset_latency_ms', 'baseline_upload_success', 'screen_width', 'screen_height', 'touch_capable', 'age', 'gender', 'extra_comments', 'study_experience', 'audio_quality', 'difficulties', 'difficulties_description', ] def custom_export(players): from otree.models import Participant, Session yield _EXPORT_COLUMNS sessions = {p.session for p in players} if not sessions: sessions = set(Session.objects_filter()) for session in sorted(sessions, key=lambda s: s.id): parts = Participant.objects_filter(session=session).order_by('id_in_session') for participant in parts: plist = participant.get_players() dp = next((p for p in plist if _app_of(p) == 'decision'), None) ob = next((p for p in plist if _app_of(p) == 'onboarding'), None) ou = next((p for p in plist if _app_of(p) == 'outro'), None) if dp is None: continue ship = participant.vars.get('shipping', {}) mean_a = ship.get('A', {}).get('mean', '') mean_b = ship.get('B', {}).get('mean', '') vis_a = ship.get('A', {}).get('visible_idx', '') vis_b = ship.get('B', {}).get('visible_idx', '') cb = _maybe(dp, 'comprehension_bonus') yield [ session.code, participant.code, participant.label or '', participant.id_in_session, _maybe(participant, 'finished'), _maybe(dp, 'condition'), _maybe(dp, 'swap_ab'), _maybe(dp, 'faster_company'), round(mean_a, 3) if mean_a != '' else '', round(mean_b, 3) if mean_b != '' else '', ' '.join(str(i + 1) for i in vis_a) if vis_a != '' else '', ' '.join(str(i + 1) for i in vis_b) if vis_b != '' else '', cb, '' if cb is None else (cb == COMPREHENSION_BONUS_CORRECT), _latency_ms(_maybe(dp, 'choice_record_start_ts'), _maybe(dp, 'choice_voice_onset_ts')), _maybe(dp, 'choice_upload_success'), _maybe(dp, 'choice_coded'), _maybe(dp, 'choice_method'), _maybe(dp, 'choice_transcript'), _maybe(dp, 'choice'), _maybe(dp, 'choice_confirmed'), _choice_correct(dp), _maybe(dp, 'bdm_value'), _maybe(dp, 'bdm_draw_cents'), _maybe(dp, 'mechanism_branch'), _maybe(dp, 'bonus_cents'), _maybe(dp, 'ts_page_load'), _latency_ms( _maybe(ob, 'baseline_record_start_ts') if ob else None, _maybe(ob, 'baseline_voice_onset_ts') if ob else None, ), _maybe(ob, 'baseline_upload_success') if ob else '', _maybe(ob, 'screen_width') if ob else '', _maybe(ob, 'screen_height') if ob else '', _maybe(ob, 'touch_capable') if ob else '', _maybe(ou, 'age') if ou else '', _maybe(ou, 'gender') if ou else '', _maybe(ou, 'extra_comments') if ou else '', _maybe(ou, 'study_experience') if ou else '', _maybe(ou, 'audio_quality') if ou else '', _maybe(ou, 'difficulties') if ou else '', _maybe(ou, 'difficulties_description') if ou else '', ]