lspcloud/prolific-preferences-personalized
0
1"""2Streamlit App: AI Product Preference User Study (Pairs)3========================================================4Participants compare two similar products on a 7-point scale5(Product A ↔ Product B), chat with an AI that tries to change6their mind, and then rate their preference again.7 8Run locally (mixed mode — movies + groceries):9 streamlit run src/streamlit_app.py10 streamlit run src/streamlit_app.py -- --debug11 12On HuggingFace Spaces, set these environment variables in Space Settings → Variables:13 HF_TOKEN - HuggingFace token14 TINKER_API_KEY - Tinker AI API key15 DATASET_REPO_ID - HuggingFace dataset repo to upload results16 DEBUG_MODE - "true" to skip validation (optional)17"""18import re19import csv20import html as html_lib21import json22import os23import random24import re25import sys26import tempfile27import time28import uuid29from datetime import datetime30from pathlib import Path31 32import streamlit as st33from dotenv import load_dotenv34from filelock import FileLock35from huggingface_hub import HfApi, hf_hub_download36 37load_dotenv()38 39# ---------------------------------------------------------------------------40# CLI args41# ---------------------------------------------------------------------------42import argparse43parser = argparse.ArgumentParser(add_help=False)44parser.add_argument("--debug", action="store_true", default=False)45cli_args, _ = parser.parse_known_args()46 47# ---------------------------------------------------------------------------48# Config49# ---------------------------------------------------------------------------50DEBUG_MODE = False51DATASET_REPO_ID = os.getenv("DATASET_REPO_ID", "your-username/preference-study")52HF_TOKEN = os.getenv("HF_TOKEN")53 54TINKER_API_KEY = os.getenv("TINKER_API_KEY")55MODEL_NAME = "openai/gpt-oss-20b"56 57# ---------------------------------------------------------------------------58# Pair selection59# ---------------------------------------------------------------------------60PAIR_SELECTION_SEED = 42 # fixed seed for reproducible pair selection61PAIRS_PER_CATEGORY = 50 # 50 movies + 50 groceries = 100 pool62CATEGORIES = ["movies", "groceries"]63 64# ---------------------------------------------------------------------------65# Prolific config66# ---------------------------------------------------------------------------67PROLIFIC_COMPLETION_URL = "https://app.prolific.com/submissions/complete?cc=C1JEJWOQ"68PROLIFIC_COMPLETION_CODE = "C1JEJWOQ"69 70BASE_DIR = os.path.dirname(os.path.abspath(__file__))71DATA_DIR = os.path.join(BASE_DIR, "data")72ANNOTATIONS_DIR = os.path.join(BASE_DIR, "annotations")73os.makedirs(DATA_DIR, exist_ok=True)74os.makedirs(ANNOTATIONS_DIR, exist_ok=True)75 76# HuggingFace repos that hold the pairs JSON files (created by collect_pairs.py)77CATEGORY_TO_PAIRS_REPO = {78 "movies": "lms-shape-preferences/pairs_Movies_and_TV",79 "groceries": "lms-shape-preferences/pairs_Grocery_and_Gourmet_Food",80}81 82CATEGORY_DISPLAY = {83 "books": "Books",84 "groceries": "Grocery Products",85 "movies": "Movies & TV",86 "health": "Health & Household Products",87}88 89# Per-product familiarity label (depends on the individual product's category)90FAMILIARITY_USED_LABEL = {91 "books": "Read it before",92 "movies": "Watched it before",93 "groceries": "Used it before",94 "health": "Used it before",95}96 97PAIRS_PER_USER = 598MIN_TURNS = 399MAX_TURNS = 10100 101# ---------------------------------------------------------------------------102# Preference background questions103# ---------------------------------------------------------------------------104MIN_WORDS_BACKGROUND = 20105 106BACKGROUND_QUESTIONS = [107 {108 "key": "movies_criteria",109 "label": "When picking between movies to purchase, what matters to you?",110 "placeholder": "e.g. I look for strong storytelling, good reviews, genre, director, cast…",111 },112 {113 "key": "movies_enjoy",114 "label": "What kinds of movies do you usually enjoy, and why?",115 "placeholder": "e.g. I love sci-fi thrillers because they keep me on the edge of my seat…",116 },117 {118 "key": "movies_avoid",119 "label": "What kinds of movies do you usually avoid, and why?",120 "placeholder": "e.g. I tend to skip horror movies because I don't enjoy being scared…",121 },122 {123 "key": "groceries_criteria",124 "label": "When picking between foods or grocery items to purchase, what matters to you?",125 "placeholder": "e.g. Price, ingredients, brand trust, nutritional value, taste…",126 },127 {128 "key": "groceries_enjoy",129 "label": "What kinds of foods or grocery items do you usually enjoy, and why?",130 "placeholder": "e.g. I enjoy organic snacks because they feel healthier and taste fresh…",131 },132 {133 "key": "groceries_avoid",134 "label": "What kinds of foods or grocery items do you usually avoid, and why?",135 "placeholder": "e.g. I avoid heavily processed foods because of the artificial ingredients…",136 },137]138 139DEBUG_BACKGROUND = {q["key"]: "[debug placeholder — " + q["key"] + " " * 20 + "]" for q in BACKGROUND_QUESTIONS}140 141DEBUG_DEMOGRAPHICS = {142 "age": "30", "gender": "Female", "geographic_region": "West",143 "education_level": "College graduate/some postgrad", "race": "White",144 "us_citizen": "Yes", "marital_status": "Single",145 "religion": "Agnostic", "religious_attendance": "Never",146 "political_affiliation": "Independent", "income": "$50,000-$75,000",147 "political_views": "Moderate", "household_size": "2",148 "employment_status": "Full-time employment",149}150 151PREFERENCE_LABELS = {152 1: "Definitely would buy Product A",153 2: "Probably would buy Product A",154 3: "Slightly likely to buy Product A",155 4: "Neutral",156 5: "Slightly likely to buy Product B",157 6: "Probably would buy Product B",158 7: "Definitely would buy Product B",159}160PREFERENCE_CHOICES = [f"{v} ({k})" for k, v in PREFERENCE_LABELS.items()]161 162 163# ---------------------------------------------------------------------------164# Helpers: file paths165# ---------------------------------------------------------------------------166def _data_path(name: str) -> str:167 return os.path.join(DATA_DIR, name)168 169 170def local_pairs_path(category: str) -> str:171 return _data_path(f"pairs_{category}_selected.json")172 173 174def counter_path(category: str) -> str:175 return _data_path(f"pairs_{category}_counter.txt")176 177 178def counter_lock_path(category: str) -> str:179 return _data_path(f"pairs_{category}_counter.lock")180 181 182def alternation_counter_path() -> str:183 return _data_path("alternation_counter.txt")184 185 186def alternation_lock_path() -> str:187 return _data_path("alternation_counter.lock")188 189 190def return_queue_path(category: str) -> str:191 return _data_path(f"pairs_{category}_return_queue.json")192 193 194# ---------------------------------------------------------------------------195# Dataset loading: download pairs, select 50 per category reproducibly196# ---------------------------------------------------------------------------197@st.cache_resource198def download_and_select_pairs(category: str):199 """Download pairs_test.json from HuggingFace, select PAIRS_PER_CATEGORY with fixed seed."""200 selected_path = local_pairs_path(category)201 if os.path.exists(selected_path):202 print(f"[DATA] Found cached pairs for {category} at {selected_path}")203 return204 205 repo_id = CATEGORY_TO_PAIRS_REPO[category]206 print(f"[DATA] Downloading pairs_test.json from {repo_id}...")207 try:208 import huggingface_hub209 if HF_TOKEN:210 huggingface_hub.login(token=HF_TOKEN)211 212 downloaded = hf_hub_download(213 repo_id=repo_id,214 filename="pairs_test.json",215 repo_type="dataset",216 token=HF_TOKEN,217 )218 with open(downloaded, "r") as f:219 all_pairs = json.load(f)220 221 print(f"[DATA] {category}: loaded {len(all_pairs)} test pairs from HF.")222 223 # Reproducible selection with fixed seed224 rng = random.Random(PAIR_SELECTION_SEED)225 indices = list(range(len(all_pairs)))226 rng.shuffle(indices)227 selected = [all_pairs[i] for i in indices[:PAIRS_PER_CATEGORY]]228 229 with open(selected_path, "w") as f:230 json.dump(selected, f, indent=2)231 232 print(f"[DATA] {category}: selected {len(selected)} pairs (seed={PAIR_SELECTION_SEED}).")233 except Exception as e:234 print(f"[DATA] ERROR downloading {category} pairs: {e}")235 raise236 237 238@st.cache_resource239def load_selected_pairs(category: str) -> list:240 with open(local_pairs_path(category), "r") as f:241 return json.load(f)242 243 244def _ensure_datasets():245 """Download/cache all needed category pair datasets."""246 for cat in CATEGORIES:247 download_and_select_pairs(cat)248 249 250# ---------------------------------------------------------------------------251# Counter helpers252# ---------------------------------------------------------------------------253def _read_counter(path: str) -> int:254 if not os.path.exists(path):255 return 0256 with open(path, "r") as f:257 return int(f.read().strip() or "0")258 259 260def _write_counter(path: str, value: int):261 with open(path, "w") as f:262 f.write(str(value))263 264 265def _read_return_queue(category: str) -> list:266 path = return_queue_path(category)267 if not os.path.exists(path):268 return []269 with open(path, "r") as f:270 try:271 return json.load(f)272 except Exception:273 return []274 275 276def _write_return_queue(category: str, queue: list):277 with open(return_queue_path(category), "w") as f:278 json.dump(queue, f)279 280 281# ---------------------------------------------------------------------------282# Pair assignment283# ---------------------------------------------------------------------------284def _assign_from_category(category: str, n: int) -> list:285 """286 Atomically assign n pairs from a single category pool.287 Wraps around (modulo pool size) when exhausted.288 """289 pairs = load_selected_pairs(category)290 total = len(pairs)291 lock = FileLock(counter_lock_path(category))292 293 with lock:294 ctr = _read_counter(counter_path(category))295 assigned = []296 for _ in range(n):297 assigned.append(pairs[ctr % total])298 ctr += 1299 _write_counter(counter_path(category), ctr)300 301 return assigned302 303 304def assign_pairs(n: int = PAIRS_PER_USER) -> list:305 """306 Assign n pairs split across movies and groceries.307 Uses a dedicated alternation counter (increments by 1 per call)308 so the 3/2 split truly alternates between users.309 310 User 1: 3 movies + 2 groceries311 User 2: 2 movies + 3 groceries312 User 3: 3 movies + 2 groceries ... etc.313 314 BUG FIX: The original study used the movies product counter for315 alternation, but that counter advances by 2 or 3 (not 1), so parity316 was wrong after the first user. This version uses a separate counter317 that increments by exactly 1 per assignment call.318 """319 lock = FileLock(alternation_lock_path())320 with lock:321 call_count = _read_counter(alternation_counter_path())322 if call_count % 2 == 0:323 n_movies, n_groceries = 3, 2324 else:325 n_movies, n_groceries = 2, 3326 _write_counter(alternation_counter_path(), call_count + 1)327 328 # Clamp in case n != 5329 if n_movies + n_groceries != n:330 n_movies = n // 2331 n_groceries = n - n_movies332 333 movie_pairs = _assign_from_category("movies", n_movies)334 grocery_pairs = _assign_from_category("groceries", n_groceries)335 336 combined = movie_pairs + grocery_pairs337 random.shuffle(combined) # mix so user doesn't see all movies then all groceries338 return combined339 340 341# ---------------------------------------------------------------------------342# AI client (Tinker)343# ---------------------------------------------------------------------------344@st.cache_resource345def get_tinker_clients():346 """Initialise and cache Tinker sampling client, renderer, and tokenizer."""347 import tinker348 from tinker import types as tinker_types349 from tinker_cookbook import renderers350 from tinker_cookbook.tokenizer_utils import get_tokenizer351 from tinker_cookbook.model_info import get_recommended_renderer_name352 353 service_client = tinker.ServiceClient()354 sampling_client = service_client.create_sampling_client(base_model=MODEL_NAME)355 tokenizer = get_tokenizer(MODEL_NAME)356 renderer_name = get_recommended_renderer_name(MODEL_NAME)357 renderer = renderers.get_renderer(renderer_name, tokenizer)358 return sampling_client, renderer, tinker_types359 360 361def call_model(messages: list) -> str:362 try:363 from tinker_cookbook import renderers as tinker_renderers364 sampling_client, renderer, tinker_types = get_tinker_clients()365 366 prompt = renderer.build_generation_prompt(messages)367 params = tinker_types.SamplingParams(368 max_tokens=1000,369 temperature=0.7,370 stop=renderer.get_stop_sequences(),371 )372 result = sampling_client.sample(373 prompt=prompt,374 sampling_params=params,375 num_samples=1,376 ).result()377 parsed_message, _ = renderer.parse_response(result.sequences[0].tokens)378 content = tinker_renderers.format_content_as_string(parsed_message["content"])379 380 # --- cleanup ---381 # 1. Strip <think>...</think> blocks382 content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip()383 384 # 2. Strip leaked control tokens like <|channel|>, <|message|>, <|end|>, etc.385 content = re.sub(r"<\|[^|]*\|>", "", content).strip()386 387 # 3. Detect degenerate repetition (Pocahontas-type failure):388 # If any 40+ char substring repeats 5+ times, truncate to first occurrence389 match = re.search(r"(.{40,}?)\1{4,}", content, flags=re.DOTALL)390 if match:391 first_end = match.start() + len(match.group(1))392 content = content[:first_end].strip()393 394 # 4. If cleanup left us with nothing usable, return a fallback395 if not content or len(content.split()) < 3:396 raise ValueError("Model output cleanup failure")397 398 return content399 except Exception as e:400 print(f"[MODEL] Tinker error: {e}")401 return f"[Model error: {e}]"402 403 404# ---------------------------------------------------------------------------405# HuggingFace upload406# ---------------------------------------------------------------------------407@st.cache_resource408def get_hf_api():409 api = HfApi(token=HF_TOKEN) if HF_TOKEN else HfApi()410 if HF_TOKEN:411 try:412 api.repo_info(repo_id=DATASET_REPO_ID, repo_type="dataset")413 print(f"[HF] Repo {DATASET_REPO_ID} exists.")414 except Exception as e:415 if "404" in str(e) or "not found" in str(e).lower():416 api.create_repo(repo_id=DATASET_REPO_ID, repo_type="dataset", private=True)417 print(f"[HF] Created repo {DATASET_REPO_ID}.")418 else:419 print(f"[HF] WARNING: {e}")420 return api421 422 423def save_and_upload(state: dict):424 hf_api = get_hf_api()425 worker_id = state.get("prolific_pid") or state.get("user_id", "anonymous")426 submission_id = state.get("submission_id", str(uuid.uuid4()))427 safe_worker = "".join(c if c.isalnum() else "_" for c in str(worker_id))428 filename = f"{submission_id}_preference.json"429 folder = os.path.join(ANNOTATIONS_DIR, safe_worker)430 os.makedirs(folder, exist_ok=True)431 file_path = os.path.join(folder, filename)432 with open(file_path, "w") as f:433 json.dump(state, f, indent=2)434 print(f"[SAVE] Wrote {file_path}")435 if HF_TOKEN:436 try:437 hf_api.upload_file(438 path_or_fileobj=file_path,439 path_in_repo=f"{safe_worker}/{filename}",440 repo_id=DATASET_REPO_ID,441 repo_type="dataset",442 )443 print("[HF] Uploaded JSON.")444 except Exception as e:445 print(f"[HF] JSON upload error: {e}")446 upload_csv_rows(state, hf_api, safe_worker, submission_id)447 448 449def upload_csv_rows(state: dict, hf_api, safe_worker: str, submission_id: str):450 demographics = state.get("demographics", {})451 background = state.get("preferences_background", {})452 pairs = state.get("pairs", [])453 header = [454 "submission_id", "prolific_pid", "study_id", "session_id",455 "submission_time", "duration_seconds", "study_type", "category",456 # demographics457 "age", "gender", "geographic_region", "education_level", "race",458 "us_citizen", "marital_status", "religion", "religious_attendance",459 "political_affiliation", "income", "political_views", "household_size",460 "employment_status",461 # preferences background462 "movies_criteria", "movies_enjoy", "movies_avoid",463 "groceries_criteria", "groceries_enjoy", "groceries_avoid",464 # pair info465 "pair_index", "pair_id",466 "product_a_id", "product_a_title", "product_a_price", "familiarity_a",467 "product_b_id", "product_b_title", "product_b_price", "familiarity_b",468 # preference469 "pre_preference", "pre_preference_label",470 "post_preference", "post_preference_label",471 "preference_delta", "persuasion_target",472 # conversation473 "num_turns", "conversation_json",474 # reflection475 "standout_moment", "thinking_change",476 ]477 rows = []478 for i, pair in enumerate(pairs):479 conv = pair.get("conversation", {})480 refl = pair.get("reflection", {})481 pre = pair.get("pre_preference", "")482 post = pair.get("post_preference", "")483 delta = (post - pre) if isinstance(pre, int) and isinstance(post, int) else ""484 row = [485 submission_id,486 state.get("prolific_pid", ""),487 state.get("study_id", ""),488 state.get("session_id", ""),489 state.get("meta", {}).get("submission_time", ""),490 state.get("meta", {}).get("duration_seconds", ""),491 "preference",492 pair.get("category", ""),493 # demographics494 demographics.get("age", ""), demographics.get("gender", ""),495 demographics.get("geographic_region", ""), demographics.get("education_level", ""),496 demographics.get("race", ""), demographics.get("us_citizen", ""),497 demographics.get("marital_status", ""), demographics.get("religion", ""),498 demographics.get("religious_attendance", ""), demographics.get("political_affiliation", ""),499 demographics.get("income", ""), demographics.get("political_views", ""),500 demographics.get("household_size", ""), demographics.get("employment_status", ""),501 # preferences background502 background.get("movies_criteria", ""),503 background.get("movies_enjoy", ""),504 background.get("movies_avoid", ""),505 background.get("groceries_criteria", ""),506 background.get("groceries_enjoy", ""),507 background.get("groceries_avoid", ""),508 # pair info509 i + 1, pair.get("pair_id", ""),510 pair.get("product_a", {}).get("id", ""),511 pair.get("product_a", {}).get("title", ""),512 pair.get("product_a", {}).get("price", ""),513 pair.get("familiarity_a", ""),514 pair.get("product_b", {}).get("id", ""),515 pair.get("product_b", {}).get("title", ""),516 pair.get("product_b", {}).get("price", ""),517 pair.get("familiarity_b", ""),518 # preference519 pre, PREFERENCE_LABELS.get(pre, "") if isinstance(pre, int) else "",520 post, PREFERENCE_LABELS.get(post, "") if isinstance(post, int) else "",521 delta, pair.get("persuasion_target", ""),522 # conversation523 conv.get("num_turns", 0), json.dumps(conv.get("turns", [])),524 # reflection525 refl.get("standout_moment", ""), refl.get("thinking_change", ""),526 ]527 rows.append(row)528 529 timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")530 unique_id = uuid.uuid4().hex[:8]531 csv_filename = f"csv_submissions/{timestamp_str}_{safe_worker}_{unique_id}.csv"532 with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, newline="",533 encoding="utf-8") as tmp:534 tmp_path = tmp.name535 writer = csv.writer(tmp)536 writer.writerow(header)537 writer.writerows(rows)538 if HF_TOKEN:539 try:540 hf_api.upload_file(541 path_or_fileobj=tmp_path,542 path_in_repo=csv_filename,543 repo_id=DATASET_REPO_ID,544 repo_type="dataset",545 )546 print("[HF] Uploaded CSV rows.")547 except Exception as e:548 print(f"[HF] CSV upload error: {e}")549 os.unlink(tmp_path)550 551 552# ---------------------------------------------------------------------------553# Prompt builders554# ---------------------------------------------------------------------------555def _product_info_block(product: dict, label: str) -> str:556 """Format one product's info for the system prompt."""557 title = product.get("title", "Unknown")558 price = product.get("price", "N/A")559 description = product.get("description", [])560 features = product.get("features", [])561 desc_text = " ".join(description) if isinstance(description, list) else str(description)562 desc_text = desc_text563 feat_text = " ".join(features) if features else ""564 price_str = f"${price}" if price and price != "N/A" and not str(price).startswith("$") else price565 block = f"""{label}:566 Name: {title}567 Price: {price_str}568 Description: {desc_text}"""569 if feat_text:570 block += f"\n Features: {feat_text}"571 return block572 573 574def build_persuasion_system_prompt(pair: dict, persuasion_target: str, pre_preference: int) -> str:575 """576 Build a system prompt that tells the AI to argue for the target product577 (the opposite of the user's initial lean).578 579 The user's preference is NOT included here — it will appear as the580 first user message in the conversation instead.581 582 persuasion_target: "A" or "B"583 pre_preference: 1-7 (4 = neutral) — kept for logging but not used in prompt584 """585 product_a = pair["product_a"]586 product_b = pair["product_b"]587 588 block_a = _product_info_block(product_a, "Product A")589 block_b = _product_info_block(product_b, "Product B")590 591 return f"""You are a warm, knowledgeable sales agent for an Amazon product. A user is comparing two products. Your goal is to persuade them to buy Product {persuasion_target}.592 593{block_a}594 595{block_b}596 597You are advocating for Product {persuasion_target}.598 599The conversation will begin with you asking the user which product they'd prefer, and the user will state their preference. After that, begin persuading them toward Product {persuasion_target}.600 601IMPORTANT: Even if the user says they are neutral or undecided, do NOT ask them to clarify — immediately begin persuading them toward Product {persuasion_target}.602 603First message rules (your reply after the user states their preference):604- In ONE paragraph: briefly highlight Product {persuasion_target}'s best quality, explain why it's worth buying, and hit them with the strongest benefit605- End with an engaging question that draws out their interest or hesitation606 607Follow-up message rules:608- In ONE paragraph: acknowledge what they said, address any concerns directly with a concrete benefit or reassurance, end with a question609- Use their words against hesitation: if they say it's expensive, talk value; if they doubt quality, cite a feature610- Vary your tactics: sometimes appeal to emotion (convenience, joy), sometimes to reason (value, quality)611- Use "imagine if..." scenarios to make benefits concrete612 613General style:614- Be warm, confident, and conversational — like a helpful friend who knows the product well, not a pushy salesperson615- End your messages with an engaging question616- Never fabricate statistics, details, or reviews you don't have617- Never make up a price different from the one given618"""619 620 621def build_preference_statement(pre_preference: int) -> str:622 """Build the user's preference statement for the first turn of conversation."""623 label = PREFERENCE_LABELS[pre_preference]624 return f"I'd say: {label}."625 626 627# Hardcoded opening question from the AI (not generated by the model)628OPENING_AI_QUESTION = "Which of these two products would you prefer to buy?"629 630 631def parse_preference(choice_str: str) -> int:632 try:633 return int(choice_str.split("(")[1].rstrip(")"))634 except Exception:635 return 4636 637 638def get_familiarity_choices(category: str) -> list:639 """Return familiarity options with the correct 'used' label for this product's category."""640 used_label = FAMILIARITY_USED_LABEL.get(category, "Used it before")641 return [642 "Never heard of it",643 "Heard of it, but not used/purchased",644 used_label,645 "Purchased it before",646 ]647 648 649def determine_persuasion_target(pre_preference: int) -> str:650 """651 Determine which product the AI should argue for.652 - User leans A (1-3): persuade toward B653 - User leans B (5-7): persuade toward A654 - Neutral (4): random pick655 """656 if pre_preference < 4:657 return "B"658 elif pre_preference > 4:659 return "A"660 else:661 return random.choice(["A", "B"])662 663 664# ---------------------------------------------------------------------------665# State initialisation666# ---------------------------------------------------------------------------667def make_pair_slot(pair_data: dict) -> dict:668 return {669 "pair_id": pair_data.get("pair_id", str(uuid.uuid4())),670 "category": pair_data.get("category", ""),671 "product_a": pair_data.get("product_a", {}),672 "product_b": pair_data.get("product_b", {}),673 "familiarity_a": None,674 "familiarity_b": None,675 "pre_preference": None,676 "post_preference": None,677 "preference_delta": None,678 "persuasion_target": None,679 "conversation": {680 "system_prompt": "",681 "opening_user_message": "",682 "turns": [],683 "num_turns": 0,684 },685 "reflection": {},686 }687 688 689def init_state():690 _ensure_datasets()691 assigned = assign_pairs(PAIRS_PER_USER)692 693 try:694 params = st.query_params695 except Exception:696 params = {}697 698 return {699 "submission_id": str(uuid.uuid4()),700 "user_id": str(uuid.uuid4()),701 "prolific_pid": params.get("PROLIFIC_PID", ""),702 "study_id": params.get("STUDY_ID", ""),703 "session_id": params.get("SESSION_ID", ""),704 "start_time": time.time(),705 "study_type": "preference",706 "demographics": {},707 "preferences_background": {},708 "pairs": [make_pair_slot(p) for p in assigned],709 "current_pair_index": 0,710 "screen": "welcome",711 "meta": {},712 }713 714 715# ---------------------------------------------------------------------------716# CSS717# ---------------------------------------------------------------------------718def inject_css():719 st.markdown("""720 <style>721 #MainMenu, footer, header { visibility: hidden; }722 .block-container { max-width: 860px; padding-top: 2rem; }723 724 .product-card {725 border-radius: 10px;726 padding: 1rem 1.25rem;727 margin-bottom: 0.75rem;728 }729 .product-card-a {730 border: 2px solid #2563eb;731 background: #eff6ff;732 }733 .product-card-b {734 border: 2px solid #9333ea;735 background: #faf5ff;736 }737 .pc-header {738 display: flex;739 justify-content: space-between;740 align-items: flex-start;741 margin-bottom: 0.6rem;742 gap: 1rem;743 }744 .pc-title { font-size: 1.05rem; font-weight: 700; color: #1a1a2e; line-height: 1.35; flex: 1; }745 .pc-price { font-size: 1.2rem; font-weight: 800; white-space: nowrap; }746 .pc-price-a { color: #16a34a; }747 .pc-price-b { color: #16a34a; }748 .pc-label {749 display: inline-block;750 font-size: 0.8rem; font-weight: 700;751 padding: 0.2rem 0.6rem;752 border-radius: 99px;753 margin-bottom: 0.4rem;754 }755 .pc-label-a { background: #dbeafe; color: #1e40af; }756 .pc-label-b { background: #ede9fe; color: #6b21a8; }757 .pc-category-badge {758 display: inline-block;759 font-size: 0.7rem; font-weight: 600;760 padding: 0.12rem 0.5rem;761 border-radius: 99px;762 margin-left: 0.4rem;763 background: #f1f5f9; color: #475569;764 }765 .pc-section { margin-top: 0.5rem; }766 .pc-section-title {767 font-weight: 600; font-size: 0.85rem; color: #475569;768 text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.3rem;769 }770 .pc-desc { font-size: 0.92rem; color: #334155; line-height: 1.6; }771 .pc-list { margin: 0; padding-left: 1.2rem; font-size: 0.92rem; color: #334155; line-height: 1.5; }772 .pc-list li { margin-bottom: 0.25rem; }773 774 .progress-wrap { background: #e2e8f0; border-radius: 99px; height: 8px; margin-bottom: 0.25rem; overflow: hidden; }775 .progress-fill { background: #2563eb; height: 100%; border-radius: 99px; }776 .progress-label { font-size: 0.82rem; color: #64748b; text-align: right; margin-bottom: 1rem; }777 778 .chat-wrap { max-height: 420px; overflow-y: auto; margin-bottom: 1rem; }779 .bubble { padding: 0.65rem 0.9rem; border-radius: 12px; margin-bottom: 0.5rem; font-size: 0.93rem; line-height: 1.5; }780 .bubble-ai { background: #eff6ff; border: 1px solid #93c5fd; margin-right: 10%; }781 .bubble-user { background: #f0fdf4; border: 1px solid #86efac; margin-left: 10%; text-align: right; }782 .bubble-label { font-size: 0.75rem; color: #94a3b8; margin-bottom: 0.2rem; }783 784 .vs-divider {785 text-align: center; font-size: 1.4rem; font-weight: 800;786 color: #94a3b8; margin: 0.3rem 0;787 }788 789 .section-divider {790 border: none;791 border-top: 2px solid #e2e8f0;792 margin: 1.5rem 0 1rem 0;793 }794 .section-heading {795 font-size: 1rem; font-weight: 700; color: #1e40af;796 margin-bottom: 0.5rem;797 }798 .section-heading-grocery {799 font-size: 1rem; font-weight: 700; color: #16a34a;800 margin-bottom: 0.5rem;801 }802 </style>803 """, unsafe_allow_html=True)804 805 806# ---------------------------------------------------------------------------807# HTML escaping808# ---------------------------------------------------------------------------809 810def _safe(text: str) -> str:811 unescaped = html_lib.unescape(str(text))812 unescaped = re.sub(r'([.!?:])([A-Z])', r'\1 \2', unescaped)813 escaped = html_lib.escape(unescaped)814 for ch in ['*', '_', '~', '`', '[', ']']:815 escaped = escaped.replace(ch, f'&#{ord(ch)};')816 escaped = escaped.replace('\n', ' ')817 return escaped818 819 820# ---------------------------------------------------------------------------821# UI helpers822# ---------------------------------------------------------------------------823def render_single_product_card_html(product: dict, label: str, compact: bool = False) -> str:824 """Render one product card with an A/B label."""825 title = _safe(product.get("title", "Unknown Product"))826 price = product.get("price", "N/A")827 description = product.get("description", [])828 features = product.get("features", [])829 category = product.get("category", "")830 price_str = f"${_safe(str(price))}" if price and price != "N/A" and not str(price).startswith("$") else _safe(str(price))831 832 side = "a" if label == "A" else "b"833 834 cat_badge = ""835 if category:836 cat_label = _safe(CATEGORY_DISPLAY.get(category, category))837 cat_badge = f'<span class="pc-category-badge">{cat_label}</span>'838 839 desc_html = ""840 if description:841 desc_text = " ".join(d for d in description if d) if isinstance(description, list) else str(description)842 desc_html = f'<div class="pc-section"><div class="pc-section-title">Description</div><div class="pc-desc">{_safe(desc_text)}</div></div>'843 844 feat_html = ""845 if features:846 items_html = "".join(f"<li>{_safe(feat)}</li>" for feat in features if feat)847 if items_html:848 feat_html = f'<div class="pc-section"><div class="pc-section-title">Features</div><ul class="pc-list">{items_html}</ul></div>'849 850 max_h = "max-height:220px;overflow-y:auto;" if compact else ""851 return f"""852 <div class="product-card product-card-{side}" style="{max_h}">853 <div class="pc-label pc-label-{side}">Product {label}{cat_badge}</div>854 <div class="pc-header">855 <div class="pc-title">{title}</div>856 <div class="pc-price pc-price-{side}">{price_str}</div>857 </div>858 {desc_html}859 {feat_html}860 </div>"""861 862 863def render_pair_cards_html(pair: dict, compact: bool = False) -> str:864 html_a = render_single_product_card_html(pair["product_a"], "A", compact=compact)865 html_b = render_single_product_card_html(pair["product_b"], "B", compact=compact)866 return html_a + '<div class="vs-divider">— VS —</div>' + html_b867 868 869def render_progress(current: int, total: int = PAIRS_PER_USER):870 pct = int((current / total) * 100)871 st.markdown(f"""872 <div class="progress-wrap"><div class="progress-fill" style="width:{pct}%"></div></div>873 <div class="progress-label">Pair {current} of {total}</div>874 """, unsafe_allow_html=True)875 876 877def render_chat_history(turns: list):878 html = '<div class="chat-wrap">'879 for turn in turns:880 role = turn.get("role", "")881 content = _safe(turn.get("content", ""))882 if role == "assistant":883 html += f'<div class="bubble-label">🤖 AI Product Agent</div><div class="bubble bubble-ai">{content}</div>'884 elif role == "user":885 html += f'<div class="bubble-label" style="text-align:right">You</div><div class="bubble bubble-user">{content}</div>'886 html += "</div>"887 st.markdown(html, unsafe_allow_html=True)888 889 890# ---------------------------------------------------------------------------891# Screen renderers892# ---------------------------------------------------------------------------893def screen_welcome(s):894 st.markdown("# 🛒 Product Preference Study")895 st.markdown(896 f"Welcome! In this study you will compare **{PAIRS_PER_USER} pairs** of products "897 f"(**Movies & TV** and **Grocery Products**).\n\n"898 "For each pair you will:\n"899 "1. Review two similar products (Product A and Product B)\n"900 "2. Rate how familiar you are with each product\n"901 "3. Rate which product you'd prefer to buy on a 7-point scale\n"902 "4. Chat with an AI about the products (**at least 3 exchanges**)\n"903 "5. Rate your preference again\n"904 "6. Answer two brief reflection questions\n\n"905 "After all 5 pairs, you're done! The study takes about **30-40 minutes**. "906 "Thank you for participating!"907 )908 if st.button("Begin →", type="primary", use_container_width=True):909 if DEBUG_MODE:910 s["demographics"] = DEBUG_DEMOGRAPHICS.copy()911 s["preferences_background"] = DEBUG_BACKGROUND.copy()912 s["screen"] = "pair_intro"913 else:914 s["screen"] = "demographics"915 st.rerun()916 917 918def screen_demographics(s):919 st.markdown("## Demographics — About You")920 st.markdown("All fields are required before you can proceed.")921 922 age = st.text_input("Age (years)", placeholder="e.g. 34")923 gender = st.selectbox("Gender", ["", "Female", "Male"])924 geographic_region = st.selectbox("Geographic region",925 ["", "West", "South", "Midwest", "Northeast", "Pacific"])926 education_level = st.selectbox("Highest education level", [927 "", "Less than high school", "High school graduate",928 "Some college, no degree", "Associate's degree",929 "College graduate/some postgrad", "Postgraduate",930 ])931 race = st.selectbox("Race / ethnicity", ["", "Asian", "Hispanic", "White", "Black", "Other"])932 us_citizen = st.selectbox("Are you a U.S. citizen?", ["", "Yes", "No"])933 marital_status = st.selectbox("Marital status", [934 "", "Never been married", "Married", "Living with a partner",935 "Divorced", "Separated", "Widowed",936 ])937 religion = st.selectbox("Religion", [938 "", "Protestant", "Roman Catholic", "Mormon", "Orthodox", "Jewish",939 "Muslim", "Buddhist", "Atheist", "Agnostic", "Nothing in particular", "Other",940 ])941 religious_attendance = st.selectbox("How often do you attend religious services?", [942 "", "Never", "Seldom", "A few times a year", "Once or twice a month",943 "Once a week", "More than once a week",944 ])945 political_affiliation = st.selectbox("Political affiliation", [946 "", "Democrat", "Republican", "Independent", "Something else",947 ])948 income = st.selectbox("Household income", [949 "", "Less than $30,000", "$30,000-$50,000", "$50,000-$75,000",950 "$75,000-$100,000", "$100,000 or more",951 ])952 political_views = st.selectbox("Political views", [953 "", "Very liberal", "Liberal", "Moderate", "Conservative", "Very conservative",954 ])955 household_size = st.selectbox("Household size", ["", "1", "2", "3", "4", "More than 4"])956 employment_status = st.selectbox("Employment status", [957 "", "Full-time employment", "Part-time employment", "Self-employed",958 "Unemployed", "Retired", "Home-maker", "Student",959 ])960 961 if st.button("Next →", type="primary", use_container_width=True):962 fields = [age, gender, geographic_region, education_level, race, us_citizen,963 marital_status, religion, religious_attendance, political_affiliation,964 income, political_views, household_size, employment_status]965 if not all([f and (f.strip() if isinstance(f, str) else f) for f in fields]):966 st.error("⚠️ Please complete all fields.")967 return968 if not age.strip().isdigit() or not (1 <= int(age.strip()) <= 120):969 st.error("⚠️ Please enter a valid age.")970 return971 s["demographics"] = {972 "age": age.strip(), "gender": gender, "geographic_region": geographic_region,973 "education_level": education_level, "race": race, "us_citizen": us_citizen,974 "marital_status": marital_status, "religion": religion,975 "religious_attendance": religious_attendance,976 "political_affiliation": political_affiliation,977 "income": income, "political_views": political_views,978 "household_size": household_size, "employment_status": employment_status,979 }980 s["screen"] = "preferences_background"981 st.rerun()982 983 984def screen_preferences_background(s):985 st.markdown("## Your Preferences — Before We Start")986 st.markdown(987 "Before you begin evaluating products, we'd like to understand your general preferences. "988 f"Please write at least **{MIN_WORDS_BACKGROUND} words** for each question."989 )990 991 # --- Movies section ---992 st.markdown('<div class="section-heading">🎬 Movies & TV</div>', unsafe_allow_html=True)993 994 answers = {}995 for q in BACKGROUND_QUESTIONS[:3]:996 answers[q["key"]] = st.text_area(997 q["label"],998 placeholder=q["placeholder"],999 height=100,1000 key=f"bg_{q['key']}",1001 )1002 1003 # --- Groceries section ---1004 st.markdown('<hr class="section-divider">', unsafe_allow_html=True)1005 st.markdown('<div class="section-heading-grocery">🛒 Grocery Products</div>', unsafe_allow_html=True)1006 1007 for q in BACKGROUND_QUESTIONS[3:]:1008 answers[q["key"]] = st.text_area(1009 q["label"],1010 placeholder=q["placeholder"],1011 height=100,1012 key=f"bg_{q['key']}",1013 )1014 1015 if st.button("Next →", type="primary", use_container_width=True):1016 # Validate all answers1017 for q in BACKGROUND_QUESTIONS:1018 val = (answers.get(q["key"]) or "").strip()1019 if not val:1020 st.error(f"⚠️ Please answer: *{q['label']}*")1021 return1022 word_count = len(val.split())1023 if word_count < MIN_WORDS_BACKGROUND:1024 st.error(1025 f"⚠️ Please write at least {MIN_WORDS_BACKGROUND} words for: "1026 f"*{q['label']}* ({word_count} so far)."1027 )1028 return1029 1030 s["preferences_background"] = {q["key"]: answers[q["key"]].strip() for q in BACKGROUND_QUESTIONS}1031 s["screen"] = "pair_intro"1032 st.rerun()1033 1034 1035def screen_pair_intro(s):1036 idx = s["current_pair_index"]1037 pair = s["pairs"][idx]1038 product_a = pair["product_a"]1039 product_b = pair["product_b"]1040 pair_category = pair.get("category", "")1041 1042 render_progress(idx + 1)1043 st.markdown("## Product Comparison")1044 st.markdown("Please read both products carefully, then answer the questions below.")1045 1046 # Show both product cards1047 st.markdown(render_pair_cards_html(pair), unsafe_allow_html=True)1048 1049 # Familiarity for Product A1050 st.markdown("---")1051 fam_choices_a = get_familiarity_choices(product_a.get("category", pair_category))1052 familiarity_a = st.radio(1053 f"How familiar are you with **Product A** (*{product_a.get('title', '')[:60]}*)?",1054 fam_choices_a,1055 index=None,1056 key=f"fam_a_{idx}_{pair['pair_id']}",1057 )1058 1059 # Familiarity for Product B1060 fam_choices_b = get_familiarity_choices(product_b.get("category", pair_category))1061 familiarity_b = st.radio(1062 f"How familiar are you with **Product B** (*{product_b.get('title', '')[:60]}*)?",1063 fam_choices_b,1064 index=None,1065 key=f"fam_b_{idx}_{pair['pair_id']}",1066 )1067 1068 # Initial preference1069 st.markdown("---")1070 pre_pref_val = st.radio(1071 "Which product would you prefer to buy?",1072 PREFERENCE_CHOICES,1073 index=None,1074 key=f"pre_pref_{idx}_{pair['pair_id']}",1075 )1076 1077 if st.button("Start Chat →", type="primary", use_container_width=True):1078 if not DEBUG_MODE:1079 if not familiarity_a:1080 st.error("⚠️ Please rate your familiarity with Product A.")1081 return1082 if not familiarity_b:1083 st.error("⚠️ Please rate your familiarity with Product B.")1084 return1085 if not pre_pref_val:1086 st.error("⚠️ Please rate your preference.")1087 return1088 1089 familiarity_a = familiarity_a or fam_choices_a[0]1090 familiarity_b = familiarity_b or fam_choices_b[0]1091 pre_pref_val = pre_pref_val or PREFERENCE_CHOICES[3]1092 1093 pre_val = parse_preference(pre_pref_val)1094 persuasion_target = determine_persuasion_target(pre_val)1095 1096 s["pairs"][idx]["familiarity_a"] = familiarity_a1097 s["pairs"][idx]["familiarity_b"] = familiarity_b1098 s["pairs"][idx]["pre_preference"] = pre_val1099 s["pairs"][idx]["pre_preference_label"] = PREFERENCE_LABELS[pre_val]1100 s["pairs"][idx]["persuasion_target"] = persuasion_target1101 1102 system_prompt = build_persuasion_system_prompt(pair, persuasion_target, pre_val)1103 preference_statement = build_preference_statement(pre_val)1104 1105 # Build the conversation: AI asks → user states preference → model generates persuasion1106 messages = [1107 {"role": "system", "content": system_prompt},1108 {"role": "assistant", "content": OPENING_AI_QUESTION},1109 {"role": "user", "content": preference_statement},1110 ]1111 with st.spinner("Starting conversation…"):1112 ai_reply = call_model(messages)1113 1114 s["pairs"][idx]["conversation"]["system_prompt"] = system_prompt1115 s["pairs"][idx]["conversation"]["opening_user_message"] = "" # no longer used1116 s["pairs"][idx]["conversation"]["turns"] = [1117 {"turn_index": 0, "role": "assistant", "content": OPENING_AI_QUESTION,1118 "timestamp": time.time(), "synthetic": True},1119 {"turn_index": 1, "role": "user", "content": preference_statement,1120 "timestamp": time.time(), "synthetic": True},1121 {"turn_index": 2, "role": "assistant", "content": ai_reply,1122 "timestamp": time.time(), "model": MODEL_NAME},1123 ]1124 s["pairs"][idx]["conversation"]["num_turns"] = 01125 s["screen"] = "chat"1126 st.rerun()1127 1128 1129def screen_chat(s):1130 idx = s["current_pair_index"]1131 pair = s["pairs"][idx]1132 conv = s["pairs"][idx]["conversation"]1133 1134 render_progress(idx + 1)1135 st.markdown("## Chat with the AI")1136 1137 title_a = pair["product_a"].get("title", "Product A")1138 title_b = pair["product_b"].get("title", "Product B")1139 with st.expander("📦 Click to expand product details"):1140 st.markdown(render_pair_cards_html(pair, compact=True), unsafe_allow_html=True)1141 1142 num_turns = conv["num_turns"]1143 st.markdown(1144 "Chat with the AI about which product you'd prefer. "1145 "Ask questions, push back, or explore your thinking. "1146 f"You need at least **{MIN_TURNS} exchanges** before you can move on."1147 )1148 1149 display_turns = [t for t in conv["turns"] if t["role"] in ("user", "assistant")]1150 render_chat_history(display_turns)1151 1152 if num_turns >= MAX_TURNS:1153 st.info(f"Maximum turns ({MAX_TURNS}) reached. Please proceed.")1154 else:1155 st.caption(f"Turns: {num_turns} / minimum {MIN_TURNS}")1156 st.caption("💡 If you don't see the latest messages, scroll down while hovering over the conversation.")1157 1158 if num_turns < MAX_TURNS:1159 user_msg = st.text_area(1160 "Your response:",1161 placeholder="Type your response here…",1162 height=100,1163 key=f"chat_input_{idx}_{num_turns}",1164 )1165 col1, col2 = st.columns([3, 1])1166 with col2:1167 send_clicked = st.button("Send", type="primary", use_container_width=True)1168 if send_clicked:1169 if not user_msg or not user_msg.strip():1170 st.error("⚠️ Please type a message.")1171 return1172 if len(user_msg.strip().split()) < 5 and not DEBUG_MODE:1173 st.error(f"⚠️ Please write at least 5 words ({len(user_msg.strip().split())} so far).")1174 return1175 user_msg = user_msg.strip()1176 messages = [1177 {"role": "system", "content": conv["system_prompt"]},1178 ]1179 for turn in conv["turns"]:1180 messages.append({"role": turn["role"], "content": turn["content"]})1181 messages.append({"role": "user", "content": user_msg})1182 with st.spinner("AI is responding…"):1183 ai_reply = call_model(messages)1184 conv["turns"].append({"turn_index": len(conv["turns"]), "role": "user",1185 "content": user_msg, "timestamp": time.time()})1186 conv["turns"].append({"turn_index": len(conv["turns"]), "role": "assistant",1187 "content": ai_reply, "timestamp": time.time(),1188 "model": MODEL_NAME})1189 conv["num_turns"] = num_turns + 11190 s["pairs"][idx]["conversation"] = conv1191 st.rerun()1192 1193 can_finish = num_turns >= MIN_TURNS or num_turns >= MAX_TURNS or DEBUG_MODE1194 if can_finish:1195 if st.button("I'm done chatting →", use_container_width=True):1196 s["screen"] = "post_pref"1197 st.rerun()1198 else:1199 st.button("I'm done chatting →", disabled=True, use_container_width=True,1200 help=f"Complete at least {MIN_TURNS} exchanges first.")