CoolFace
Apppublic

lspcloud/prolific-preferences-personalized

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
streamlit_app.py1366 linesDownload Raw Back to src
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.")

Showing the first 1,200 of 1366 lines. Download the file for the rest.