CoolFace
Apppublic

lspcloud/prolific-preferences-personalized

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
components.py288 linesDownload Raw Back to ui
1"""2Shared CSS injection and all HTML-rendering helpers.3 4User-supplied text (product titles, descriptions, etc.) always passes through5_safe() before being embedded in HTML to prevent XSS.6"""7import html as _html8import re9 10import streamlit as st11 12from src.config import CATEGORY_DISPLAY, FAMILIARITY_USED_LABEL, LIKELIHOOD_LABELS, PREFERENCE_LABELS13 14 15# ── Global CSS ────────────────────────────────────────────────────────────────16 17def inject_css() -> None:18    st.markdown("""19<style>20#MainMenu, footer, header { visibility: hidden; }21.block-container { max-width: 860px; padding-top: 2rem; }22 23/* ── Product cards ───────────────────────────────────────────────────── */24.product-card {25    border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: 0.75rem;26    color: #1a1a2e !important;  /* force dark text regardless of theme */27}28.product-card-a      { border: 2px solid #2563eb; background: #eff6ff !important; }29.product-card-b      { border: 2px solid #9333ea; background: #faf5ff !important; }30.product-card-single { border: 2px solid #0891b2; background: #ecfeff !important; }31 32.pc-header {33    display: flex; justify-content: space-between;34    align-items: flex-start; margin-bottom: 0.6rem; gap: 1rem;35}36.pc-title { font-size: 1.05rem; font-weight: 700; color: #1a1a2e !important; line-height: 1.35; flex: 1; }37.pc-price { font-size: 1.2rem; font-weight: 800; white-space: nowrap; color: #16a34a !important; }38 39.pc-label {40    display: inline-block; font-size: 0.8rem; font-weight: 700;41    padding: 0.2rem 0.6rem; border-radius: 99px; margin-bottom: 0.4rem;42}43.pc-label-a      { background: #dbeafe !important; color: #1e40af !important; }44.pc-label-b      { background: #ede9fe !important; color: #6b21a8 !important; }45.pc-label-single { background: #cffafe !important; color: #155e75 !important; }46 47.pc-category-badge {48    display: inline-block; font-size: 0.7rem; font-weight: 600;49    padding: 0.12rem 0.5rem; border-radius: 99px; margin-left: 0.4rem;50    background: #f1f5f9 !important; color: #475569 !important;51}52.pc-section { margin-top: 0.5rem; }53.pc-section-title {54    font-weight: 600; font-size: 0.82rem; color: #64748b !important;55    text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.3rem;56}57.pc-desc { font-size: 0.92rem; color: #334155 !important; line-height: 1.6; }58.pc-list { margin: 0; padding-left: 1.2rem; font-size: 0.92rem; color: #334155 !important; line-height: 1.5; }59.pc-list li { margin-bottom: 0.25rem; color: #334155 !important; }60 61/* ── VS divider ──────────────────────────────────────────────────────── */62.vs-divider {63    text-align: center; font-size: 1.3rem; font-weight: 800;64    color: #94a3b8; margin: 0.2rem 0;65}66 67/* ── Progress ────────────────────────────────────────────────────────── */68.progress-wrap  { background: #e2e8f0; border-radius: 99px; height: 8px; overflow: hidden; margin-bottom: 0.25rem; }69.progress-fill  { background: #2563eb; height: 100%; border-radius: 99px; transition: width 0.3s; }70.progress-label { font-size: 0.82rem; color: #64748b; text-align: right; margin-bottom: 1rem; }71 72/* ── Chat bubbles ────────────────────────────────────────────────────── */73.chat-wrap   { max-height: 480px; overflow-y: auto; margin-bottom: 1rem; padding-right: 4px; }74.bubble      {75    padding: 0.65rem 0.9rem; border-radius: 12px; margin-bottom: 0.55rem;76    font-size: 0.93rem; line-height: 1.55;77    color: #1a1a2e !important;  /* force dark text regardless of theme */78}79.bubble-ai   {80    background: #eff6ff !important;81    border: 1px solid #93c5fd;82    margin-right: 8%;83    color: #1a1a2e !important;84}85.bubble-user {86    background: #f0fdf4 !important;87    border: 1px solid #86efac;88    margin-left: 8%;89    text-align: right;90    color: #1a1a2e !important;91}92.bubble-meta {93    font-size: 0.73rem;94    color: #64748b !important;95    margin-bottom: 0.15rem;96}97 98/* ── Section headings on background page ────────────────────────────── */99hr.section-divider { border: none; border-top: 2px solid #e2e8f0; margin: 1.5rem 0 1rem 0; }100.section-heading         { font-size: 1rem; font-weight: 700; color: #1e40af; margin-bottom: 0.5rem; }101.section-heading-grocery { font-size: 1rem; font-weight: 700; color: #16a34a; margin-bottom: 0.5rem; }102</style>103    """, unsafe_allow_html=True)104 105 106# ── HTML safety ───────────────────────────────────────────────────────────────107 108def _safe(text) -> str:109    """Escape user-supplied text for safe embedding in HTML attributes and content."""110    s = _html.unescape(str(text))111    # Lightly normalise run-on sentences common in Amazon descriptions112    s = re.sub(r"([.!?:])([A-Z])", r"\1 \2", s)113    s = _html.escape(s)114    # Escape markdown characters that Streamlit might render115    for ch in ["*", "_", "~", "`", "[", "]"]:116        s = s.replace(ch, f"&#{ord(ch)};")117    return s.replace("\n", " ")118 119 120# ── Product card HTML ─────────────────────────────────────────────────────────121 122def _product_card_html(product: dict, label: str, compact: bool = False) -> str:123    """124    Render one product as an HTML card.125    label: "A" | "B" | "single"126    compact: limit description height for the in-chat expander view.127    """128    title    = _safe(product.get("title", "Unknown Product"))129    price    = product.get("price", "N/A")130    desc     = product.get("description", [])131    features = product.get("features",    [])132    category = product.get("category",    "")133 134    price_str = (135        f"${_safe(str(price))}"136        if price and price not in ("N/A", "") and not str(price).startswith("$")137        else _safe(str(price))138    )139 140    if label == "A":141        card_cls, lbl_cls, lbl_text = "product-card-a",      "pc-label-a",      "Product A"142    elif label == "B":143        card_cls, lbl_cls, lbl_text = "product-card-b",      "pc-label-b",      "Product B"144    else:145        card_cls, lbl_cls, lbl_text = "product-card-single", "pc-label-single", "Product"146 147    cat_badge = (148        f'<span class="pc-category-badge">'149        f'{_safe(CATEGORY_DISPLAY.get(category, category))}'150        f'</span>'151        if category else ""152    )153 154    # Description155    if desc:156        desc_text = " ".join(d for d in desc if d) if isinstance(desc, list) else str(desc)157        overflow  = "max-height:180px;overflow-y:auto;" if compact else ""158        desc_html = (159            f'<div class="pc-section">'160            f'<div class="pc-section-title">Description</div>'161            f'<div class="pc-desc" style="{overflow}">{_safe(desc_text)}</div>'162            f'</div>'163        )164    else:165        desc_html = ""166 167    # Features168    if features:169        feat_items = [f for f in features if f] if isinstance(features, list) else [str(features)]170        if feat_items:171            lis       = "".join(f"<li>{_safe(f)}</li>" for f in feat_items)172            feat_html = (173                f'<div class="pc-section">'174                f'<div class="pc-section-title">Features</div>'175                f'<ul class="pc-list">{lis}</ul>'176                f'</div>'177            )178        else:179            feat_html = ""180    else:181        feat_html = ""182 183    return (184        f'<div class="product-card {card_cls}">'185        f'<div class="pc-label {lbl_cls}">{lbl_text}{cat_badge}</div>'186        f'<div class="pc-header">'187        f'<div class="pc-title">{title}</div>'188        f'<div class="pc-price">{price_str}</div>'189        f'</div>'190        f'{desc_html}{feat_html}'191        f'</div>'192    )193 194 195def render_pair_cards(pair: dict, compact: bool = False) -> None:196    """Render Product A and Product B side by side with a VS divider."""197    html = (198        _product_card_html(pair["product_a"], "A", compact=compact)199        + '<div class="vs-divider">— VS —</div>'200        + _product_card_html(pair["product_b"], "B", compact=compact)201    )202    st.markdown(html, unsafe_allow_html=True)203 204 205def render_single_card(product: dict, compact: bool = False) -> None:206    """Render a single product card."""207    st.markdown(_product_card_html(product, "single", compact=compact), unsafe_allow_html=True)208 209 210# ── Progress bar ──────────────────────────────────────────────────────────────211 212def render_progress(current: int, total: int) -> None:213    pct   = int((current / total) * 100)214    label = "Pair" if total > 1 else "Item"215    st.markdown(216        f'<div class="progress-wrap">'217        f'<div class="progress-fill" style="width:{pct}%"></div>'218        f'</div>'219        f'<div class="progress-label">{label} {current} of {total}</div>',220        unsafe_allow_html=True,221    )222 223 224# ── Chat bubble renderer ──────────────────────────────────────────────────────225 226def render_chat_history(turns: list, study_type: str) -> None:227    """228    Display conversation turns as styled chat bubbles.229 230    Synthetic user turns contain a raw <choice>N</choice> tag — these are231    translated to a human-readable label (e.g. "My rating: Neutral") for display.232    The raw tag is preserved in state for the model; only the display changes.233    """234    labels = PREFERENCE_LABELS if study_type == "preference" else LIKELIHOOD_LABELS235 236    html = '<div class="chat-wrap">'237    for turn in turns:238        if turn.get("synthetic"):239            continue240        role    = turn.get("role", "")241        content = turn.get("content", "")242 243        if role == "assistant":244            html += (245                f'<div class="bubble-meta">🤖 AI Product Agent</div>'246                f'<div class="bubble bubble-ai">{_safe(content)}</div>'247            )248        elif role == "user":249            m = re.match(r"^\s*<choice>(\d+)</choice>\s*$", content.strip())250            if m:251                n        = int(m.group(1))252                readable = labels.get(n, f"Rating: {n}")253                display  = f"My rating: {readable}"254            else:255                display = content256            html += (257                f'<div class="bubble-meta" style="text-align:right">You</div>'258                f'<div class="bubble bubble-user">{_safe(display)}</div>'259            )260    html += "</div>"261    st.markdown(html, unsafe_allow_html=True)262 263 264# ── Rating / familiarity helpers ──────────────────────────────────────────────265 266def familiarity_choices(category: str) -> list:267    """Return the four familiarity options with the used/watched label correct for the category."""268    used = FAMILIARITY_USED_LABEL.get(category, "Used it before")269    return [270        "Never heard of it",271        "Heard of it, but not used/purchased",272        used,273        "Purchased it before",274    ]275 276 277def rating_choices(study_type: str) -> list:278    """Return Likert options formatted as 'Label (N)' for radio widgets."""279    labels = PREFERENCE_LABELS if study_type == "preference" else LIKELIHOOD_LABELS280    return [f"{v} ({k})" for k, v in labels.items()]281 282 283def parse_rating(choice_str: str) -> int:284    """Extract integer from a 'Label text (N)' formatted radio-button value."""285    try:286        return int(choice_str.split("(")[-1].rstrip(")"))287    except Exception:288        return 4  # neutral fallback