from otree.api import * import csv import io import random import httpx from pathlib import Path from dotenv import load_dotenv load_dotenv(Path.home() / ".config" / "otree" / ".env") doc = """ Shipping click-through pretest — closely mirrors the `shipping_confidence` session of studies/23-shipping, but with every voice interaction replaced by a plain click. Built to pilot comprehension, timing, and payoffs without recording any audio. Flow (single scenario): Intro (task + hypothetical points budget + between-subjects consistency framing) -> ComprehensionCheck -> ChoiceSetIntro (setup + a blank delivery table) -> ChoiceSet (the known delivery times revealed; CLICK a preliminary choice, rate confidence) -> InfoPurchase (spend 1 point per revealed delivery time, from a 10-point hypothetical budget) -> FinalDecision (CLICK the final choice) -> Results Not incentive compatible: pay is a flat participation fee, unaffected by any choice made in this app. The points budget is a hypothetical device only, kept so the information-purchase decision still feels like a real trade-off. Delivery-time data and the initial visibility masks are loaded live from the same Google Sheet as studies/23-shipping (see SHEET_ID / *_GID below), so the two studies draw on identical stimuli. If the sheet cannot be read the app falls back to built-in placeholder data and prints a warning. """ # --------------------------------------------------------------------------- # Google Sheet loader (identical to studies/23-shipping/software/decision) # --------------------------------------------------------------------------- # Sheet: "voice-shipping-study". Two tabs, both keyed by (condition, company): # # delivery times condition,company,d1,d2,d3,d4,d5,d6,d7,d8 # (8 most-recent delivery times, in business days) # # missing information condition,company,v1,v2,v3,v4,v5,v6,v7,v8 # (TRUE/FALSE, visible at the start; exactly 2 TRUE per row) # # condition is one of: "low confidence", "high confidence" # company is one of: "A", "B" # # Tabs are 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' TIMESERIES_GID = 0 # the d1..d8 delivery-time tab MISSING_INFO_GID = 1533359465 # the v1..v8 TRUE/FALSE visibility tab FRAMING_GID = 98977388 # the e1..e8 example series for the Intro figure CONDITIONS = ['low confidence', 'high confidence'] COMPANIES = ['A', 'B'] N_DELIVERIES = 8 # 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, True, False, False, False], ('low confidence', 'B'): [False, True, False, False, False, False, True, False], ('high confidence', 'A'): [True, False, False, True, False, False, False, False], ('high confidence', 'B'): [False, True, False, False, False, True, False, False], } 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_timeseries(): out = {} for row in _sheet_csv(TIMESERIES_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] timeseries: 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] timeseries row {cond}/{comp}: need numeric d1..d{N_DELIVERIES} ({e})." ) out[(cond, comp)] = days _require_all_cells(out, 'timeseries') return out def _load_missing_info(): out = {} for row in _sheet_csv(MISSING_INFO_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] missing_information: unexpected condition/company " f"{row.get('condition')!r}/{row.get('company')!r}." ) 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] missing_information row {cond}/{comp}: need v1..v{N_DELIVERIES} ({e})." ) if sum(vis) != C.N_VISIBLE_INITIAL: raise RuntimeError( f"[decision] missing_information row {cond}/{comp}: exactly " f"{C.N_VISIBLE_INITIAL} cells must be TRUE (got {sum(vis)})." ) out[(cond, comp)] = vis _require_all_cells(out, 'missing_information') return out def _load_framing(): """Example series (one row per condition, e1..e8) for the Intro-page figure.""" out = {} for row in _sheet_csv(FRAMING_GID): cond = _norm(row.get('condition')).lower() if cond not in CONDITIONS: raise RuntimeError( f"[decision] visualization: unexpected condition {cond!r}; " f"must be one of {CONDITIONS}." ) try: ex = [int(float(_norm(row[f'e{i}']))) for i in range(1, N_DELIVERIES + 1)] except (KeyError, ValueError) as e: raise RuntimeError( f"[decision] visualization row {cond}: need numeric e1..e{N_DELIVERIES} ({e})." ) out[cond] = ex for cond in CONDITIONS: if cond not in out: raise RuntimeError(f"[decision] visualization: missing row for {cond!r}.") return out def _require_all_cells(mapping, name): for cond in CONDITIONS: for comp in COMPANIES: if (cond, comp) not in mapping: raise RuntimeError( f"[decision] {name}: missing row for condition " f"{cond!r}, company {comp!r}." ) # --------------------------------------------------------------------------- class C(BaseConstants): NAME_IN_URL = 'decision' PLAYERS_PER_GROUP = None NUM_ROUNDS = 1 N_VISIBLE_INITIAL = 2 # delivery times shown for free N_HIDDEN = N_DELIVERIES - N_VISIBLE_INITIAL # 6 revealable BUDGET_POINTS = 10 # hypothetical budget for choosing the faster company REVEAL_COST_POINTS = 1 # hypothetical cost per revealed delivery time (per company) def _load_or_fallback(): try: ts = _load_timeseries() mi = _load_missing_info() 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"delivery-time (gid={TIMESERIES_GID}) and missing-info (gid={MISSING_INFO_GID}) " f"tabs before running the real study." ) return dict(_FALLBACK_TIMESERIES), dict(_FALLBACK_MISSING_INFO) TIMESERIES, MISSING_INFO = _load_or_fallback() # Illustrative example series shown on the Intro page (the "example shop", no A/B). # Loaded from the sheet's visualization tab; the arrays below are the fallback. _FALLBACK_FRAMING = { 'low confidence': [1, 7, 2, 6, 3, 7, 1, 5], 'high confidence': [4, 4, 3, 4, 4, 5, 4, 4], } def _framing_or_fallback(): try: fr = _load_framing() print(f"[decision] loaded Intro figure series from Google Sheet {SHEET_ID}") except Exception as e: print( f"[decision] WARNING: could not load the Intro figure series from the " f"Google Sheet ({e}). Falling back to built-in values." ) fr = dict(_FALLBACK_FRAMING) return {cond: dict(example=ex) for cond, ex in fr.items()} FRAMING_TEXT = _framing_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_budget = models.StringField(blank=True) # The choice is clicked, not spoken — no transcription needed, it's exact. prelim_choice = models.StringField() # 'A' / 'B', required prelim_ts_page_load = models.StringField(blank=True) prelim_ts_choice = models.StringField(blank=True) rating_confidence = models.IntegerField(blank=True, min=1, max=7) reveal_count = models.IntegerField( blank=True, min=0, max=C.N_HIDDEN, label="How many of the currently hidden delivery times would you like to reveal " "for each company?", ) final_choice = models.StringField() # 'A' / 'B', required final_ts_page_load = models.StringField(blank=True) final_ts_choice = models.StringField(blank=True) hypothetical_points = models.IntegerField(blank=True) # hypothetical outcome only, not a real payoff # --------------------------------------------------------------------------- # Session setup # --------------------------------------------------------------------------- def creating_session(subsession: Subsession): for player in subsession.get_players(): # Between-subjects confidence 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] hidden_idx = [i for i in range(N_DELIVERIES) if not vis[i]] shipping[shown] = dict( days=days, visible_idx=visible_idx, hidden_idx=hidden_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 _shown_indices(info, n_revealed): revealed = set(info['visible_idx']) newly = set(info['hidden_idx'][:n_revealed]) return revealed | newly, newly def _cell(days, i, shown, newly, 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"), ('revealed-new' if i in newly else '') def _table_rows(player: Player, n_revealed, blind=False): """Rows for the delivery-history table. blind=True hides every value ('?').""" ship = player.participant.vars['shipping'] shown_a, newly_a = _shown_indices(ship['A'], n_revealed) shown_b, newly_b = _shown_indices(ship['B'], n_revealed) rows = [] for i in range(N_DELIVERIES): a_display, a_class = _cell(ship['A']['days'], i, shown_a, newly_a, blind) b_display, b_class = _cell(ship['B']['days'], i, shown_b, newly_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 shared by ChoiceSet and FinalDecision: just the condition's illustrative example series for the mini reminder chart.""" return dict( example=FRAMING_TEXT[player.condition]['example'], labels=list(range(1, N_DELIVERIES + 1)), ) # --------------------------------------------------------------------------- # Pages # --------------------------------------------------------------------------- class Intro(Page): # Task + bonus + the between-subjects consistency framing, all on one page # (as in the source qsf's "Intro Task" block). @staticmethod def vars_for_template(player: Player): return dict(low=player.condition == 'low confidence') @staticmethod def js_vars(player: Player): return dict(example=FRAMING_TEXT[player.condition]['example'], labels=list(range(1, N_DELIVERIES + 1))) class ComprehensionCheck(Page): form_model = 'player' form_fields = ['comprehension_budget'] class ChoiceSetIntro(Page): # The setup, an illustrative example chart, and a fully blank table. Clicking # through reveals the few known values (on ChoiceSet). @staticmethod def vars_for_template(player: Player): return dict( rows=_table_rows(player, 0, blind=True), n_visible=C.N_VISIBLE_INITIAL, n_total=N_DELIVERIES, ) @staticmethod def js_vars(player: Player): return dict(example=FRAMING_TEXT[player.condition]['example'], labels=list(range(1, N_DELIVERIES + 1))) class ChoiceSet(Page): form_model = 'player' form_fields = [ 'prelim_choice', 'rating_confidence', 'prelim_ts_page_load', 'prelim_ts_choice', ] @staticmethod def vars_for_template(player: Player): return dict( rows=_table_rows(player, 0), n_visible=C.N_VISIBLE_INITIAL, n_total=N_DELIVERIES, ) @staticmethod def js_vars(player: Player): return _decision_js_vars(player) class InfoPurchase(Page): form_model = 'player' form_fields = ['reveal_count'] @staticmethod def vars_for_template(player: Player): return dict( n_hidden=C.N_HIDDEN, options=list(range(0, C.N_HIDDEN + 1)), rows=_table_rows(player, 0), # what is currently on record ) @staticmethod def js_vars(player: Player): return dict( budget_points=C.BUDGET_POINTS, cost_points=C.REVEAL_COST_POINTS, ) class FinalDecision(Page): form_model = 'player' form_fields = ['final_choice', 'final_ts_page_load', 'final_ts_choice'] @staticmethod def vars_for_template(player: Player): n = player.field_maybe_none('reveal_count') or 0 return dict( rows=_table_rows(player, n), reveal_count=n, low=player.condition == 'low confidence', ) @staticmethod def js_vars(player: Player): return _decision_js_vars(player) @staticmethod def before_next_page(player: Player, timeout_happened): # Hypothetical outcome only: 10 points for the faster company, minus 1 # point per revealed delivery time, floored at 0. Not a real payoff — # player.payoff is never touched here, so real pay is the flat # participation fee, unaffected by any choice made in this app. # final_choice is a required field, so it is always 'A' or 'B' here. n = player.field_maybe_none('reveal_count') or 0 fc = player.field_maybe_none('final_choice') if fc in ('A', 'B'): won_points = C.BUDGET_POINTS if fc == player.faster_company else 0 player.hypothetical_points = max(0, won_points - C.REVEAL_COST_POINTS * n) # Expose the hypothetical outcome to the outro app's Debriefing page. player.participant.vars['hypothetical_points'] = player.field_maybe_none('hypothetical_points') def _points(n): n = n or 0 return f"{n} point" if abs(n) == 1 else f"{n} points" class Results(Page): @staticmethod def is_displayed(player: Player): return player.round_number == C.NUM_ROUNDS @staticmethod def vars_for_template(player: Player): fc = player.field_maybe_none('final_choice') coded = fc in ('A', 'B') n = player.field_maybe_none('reveal_count') or 0 won = coded and fc == player.faster_company base_points = C.BUDGET_POINTS if won else 0 reveal_points = C.REVEAL_COST_POINTS * n return dict( coded=coded, final_choice=fc, faster_company=player.faster_company, won=won, reveal_count=n, base_points=_points(base_points), reveal_cost_points=_points(reveal_points), reveal_cost_points_neg=('−' + _points(reveal_points)), net_points=_points(player.field_maybe_none('hypothetical_points')), ) page_sequence = [ Intro, ComprehensionCheck, ChoiceSetIntro, ChoiceSet, InfoPurchase, FinalDecision, Results, ] # --------------------------------------------------------------------------- # Custom export — one row per participant, outro fields denormalised # --------------------------------------------------------------------------- COMPREHENSION_BUDGET_CORRECT = '10' 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 _final_correct(dp): fc = _maybe(dp, 'final_choice') fast = _maybe(dp, 'faster_company') if fc in ('A', 'B') and fast in ('A', 'B'): return fc == 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_budget', 'comprehension_budget_correct', 'prelim_choice', 'prelim_choice_latency_ms', 'rating_confidence', 'reveal_count', 'final_choice', 'final_choice_latency_ms', 'final_choice_correct', 'hypothetical_points', 'screen_width', 'screen_height', 'touch_capable', 'age', 'gender', 'extra_comments', 'study_experience', '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_budget') 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_BUDGET_CORRECT), _maybe(dp, 'prelim_choice'), _latency_ms(_maybe(dp, 'prelim_ts_page_load'), _maybe(dp, 'prelim_ts_choice')), _maybe(dp, 'rating_confidence'), _maybe(dp, 'reveal_count'), _maybe(dp, 'final_choice'), _latency_ms(_maybe(dp, 'final_ts_page_load'), _maybe(dp, 'final_ts_choice')), _final_correct(dp), _maybe(dp, 'hypothetical_points'), _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, 'difficulties') if ou else '', _maybe(ou, 'difficulties_description') if ou else '', ]