lspcloud/prolific-preferences-personalized
0
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