| """ |
| Shared CSS injection and all HTML-rendering helpers. |
| |
| User-supplied text (product titles, descriptions, etc.) always passes through |
| _safe() before being embedded in HTML to prevent XSS. |
| """ |
| import html as _html |
| import re |
|
|
| import streamlit as st |
| import streamlit.components.v1 as components |
|
|
| from src.config import CATEGORY_DISPLAY, FAMILIARITY_USED_LABEL, LIKELIHOOD_LABELS, PREFERENCE_LABELS |
|
|
| |
| _CHAT_SCROLL_AND_BUBBLES_CSS = """ |
| /* ββ Chat bubbles (must match page + iframe) βββββββββββββββββββββββββ */ |
| .chat-wrap { max-height: 480px; overflow-y: auto; margin-bottom: 1rem; padding-right: 4px; } |
| .bubble { |
| padding: 0.65rem 0.9rem; border-radius: 12px; margin-bottom: 0.55rem; |
| font-size: 0.93rem; line-height: 1.55; |
| color: #1a1a2e !important; |
| } |
| .bubble-ai { |
| background: #eff6ff !important; |
| border: 1px solid #93c5fd; |
| margin-right: 8%; |
| color: #1a1a2e !important; |
| } |
| .bubble-user { |
| background: #f0fdf4 !important; |
| border: 1px solid #86efac; |
| margin-left: 8%; |
| text-align: right; |
| color: #1a1a2e !important; |
| } |
| .bubble-meta { |
| font-size: 0.73rem; |
| color: #64748b !important; |
| margin-bottom: 0.15rem; |
| } |
| """ |
|
|
|
|
| |
|
|
| def inject_css() -> None: |
| st.markdown( |
| """ |
| <style> |
| #MainMenu, footer, header { visibility: hidden; } |
| .block-container { max-width: 860px; padding-top: 2rem; } |
| |
| """ |
| + _CHAT_SCROLL_AND_BUBBLES_CSS |
| + """ |
| /* ββ Product cards βββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| .product-card { |
| border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: 0.75rem; |
| color: #1a1a2e !important; /* force dark text regardless of theme */ |
| } |
| .product-card-a { border: 2px solid #2563eb; background: #eff6ff !important; } |
| .product-card-b { border: 2px solid #9333ea; background: #faf5ff !important; } |
| .product-card-single { border: 2px solid #0891b2; background: #ecfeff !important; } |
| |
| .pc-header { |
| display: flex; justify-content: space-between; |
| align-items: flex-start; margin-bottom: 0.6rem; gap: 1rem; |
| } |
| .pc-title { font-size: 1.05rem; font-weight: 700; color: #1a1a2e !important; line-height: 1.35; flex: 1; } |
| .pc-price { font-size: 1.2rem; font-weight: 800; white-space: nowrap; color: #16a34a !important; } |
| |
| .pc-label { |
| display: inline-block; font-size: 0.8rem; font-weight: 700; |
| padding: 0.2rem 0.6rem; border-radius: 99px; margin-bottom: 0.4rem; |
| } |
| .pc-label-a { background: #dbeafe !important; color: #1e40af !important; } |
| .pc-label-b { background: #ede9fe !important; color: #6b21a8 !important; } |
| .pc-label-single { background: #cffafe !important; color: #155e75 !important; } |
| |
| .pc-category-badge { |
| display: inline-block; font-size: 0.7rem; font-weight: 600; |
| padding: 0.12rem 0.5rem; border-radius: 99px; margin-left: 0.4rem; |
| background: #f1f5f9 !important; color: #475569 !important; |
| } |
| .pc-section { margin-top: 0.5rem; } |
| .pc-section-title { |
| font-weight: 600; font-size: 0.82rem; color: #64748b !important; |
| text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.3rem; |
| } |
| .pc-desc { font-size: 0.92rem; color: #334155 !important; line-height: 1.6; } |
| .pc-list { margin: 0; padding-left: 1.2rem; font-size: 0.92rem; color: #334155 !important; line-height: 1.5; } |
| .pc-list li { margin-bottom: 0.25rem; color: #334155 !important; } |
| |
| /* ββ VS divider ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| .vs-divider { |
| text-align: center; font-size: 1.3rem; font-weight: 800; |
| color: #94a3b8; margin: 0.2rem 0; |
| } |
| |
| /* ββ Progress ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| .progress-wrap { background: #e2e8f0; border-radius: 99px; height: 8px; overflow: hidden; margin-bottom: 0.25rem; } |
| .progress-fill { background: #2563eb; height: 100%; border-radius: 99px; transition: width 0.3s; } |
| .progress-label { font-size: 0.82rem; color: #64748b; text-align: right; margin-bottom: 1rem; } |
| |
| /* ββ Section headings on background page ββββββββββββββββββββββββββββββ */ |
| hr.section-divider { border: none; border-top: 2px solid #e2e8f0; margin: 1.5rem 0 1rem 0; } |
| .section-heading { font-size: 1rem; font-weight: 700; color: #1e40af; margin-bottom: 0.5rem; } |
| .section-heading-grocery { font-size: 1rem; font-weight: 700; color: #16a34a; margin-bottom: 0.5rem; } |
| </style> |
| """, |
| unsafe_allow_html=True, |
| ) |
|
|
|
|
| |
|
|
| def _safe(text) -> str: |
| """Escape user-supplied text for safe embedding in HTML attributes and content.""" |
| s = _html.unescape(str(text)) |
| |
| s = re.sub(r"([.!?:])([A-Z])", r"\1 \2", s) |
| s = _html.escape(s) |
| |
| for ch in ["*", "_", "~", "`", "[", "]"]: |
| s = s.replace(ch, f"&#{ord(ch)};") |
| return s.replace("\n", " ") |
|
|
|
|
| |
|
|
| def _product_card_html(product: dict, label: str, compact: bool = False) -> str: |
| """ |
| Render one product as an HTML card. |
| label: "A" | "B" | "single" |
| compact: limit description height for the in-chat expander view. |
| """ |
| title = _safe(product.get("title", "Unknown Product")) |
| price = product.get("price", "N/A") |
| desc = product.get("description", []) |
| features = product.get("features", []) |
| category = product.get("category", "") |
|
|
| price_str = ( |
| f"${_safe(str(price))}" |
| if price and price not in ("N/A", "") and not str(price).startswith("$") |
| else _safe(str(price)) |
| ) |
|
|
| if label == "A": |
| card_cls, lbl_cls, lbl_text = "product-card-a", "pc-label-a", "Product A" |
| elif label == "B": |
| card_cls, lbl_cls, lbl_text = "product-card-b", "pc-label-b", "Product B" |
| else: |
| card_cls, lbl_cls, lbl_text = "product-card-single", "pc-label-single", "Product" |
|
|
| cat_badge = ( |
| f'<span class="pc-category-badge">' |
| f'{_safe(CATEGORY_DISPLAY.get(category, category))}' |
| f'</span>' |
| if category else "" |
| ) |
|
|
| |
| if desc: |
| desc_text = " ".join(d for d in desc if d) if isinstance(desc, list) else str(desc) |
| overflow = "max-height:180px;overflow-y:auto;" if compact else "" |
| desc_html = ( |
| f'<div class="pc-section">' |
| f'<div class="pc-section-title">Description</div>' |
| f'<div class="pc-desc" style="{overflow}">{_safe(desc_text)}</div>' |
| f'</div>' |
| ) |
| else: |
| desc_html = "" |
|
|
| |
| if features: |
| feat_items = [f for f in features if f] if isinstance(features, list) else [str(features)] |
| if feat_items: |
| lis = "".join(f"<li>{_safe(f)}</li>" for f in feat_items) |
| feat_html = ( |
| f'<div class="pc-section">' |
| f'<div class="pc-section-title">Features</div>' |
| f'<ul class="pc-list">{lis}</ul>' |
| f'</div>' |
| ) |
| else: |
| feat_html = "" |
| else: |
| feat_html = "" |
|
|
| return ( |
| f'<div class="product-card {card_cls}">' |
| f'<div class="pc-label {lbl_cls}">{lbl_text}{cat_badge}</div>' |
| f'<div class="pc-header">' |
| f'<div class="pc-title">{title}</div>' |
| f'<div class="pc-price">{price_str}</div>' |
| f'</div>' |
| f'{desc_html}{feat_html}' |
| f'</div>' |
| ) |
|
|
|
|
| def render_pair_cards(pair: dict, compact: bool = False) -> None: |
| """Render Product A and Product B side by side with a VS divider.""" |
| html = ( |
| _product_card_html(pair["product_a"], "A", compact=compact) |
| + '<div class="vs-divider">β VS β</div>' |
| + _product_card_html(pair["product_b"], "B", compact=compact) |
| ) |
| st.markdown(html, unsafe_allow_html=True) |
|
|
|
|
| def render_single_card(product: dict, compact: bool = False) -> None: |
| """Render a single product card.""" |
| st.markdown(_product_card_html(product, "single", compact=compact), unsafe_allow_html=True) |
|
|
|
|
| |
|
|
| def render_progress(current: int, total: int) -> None: |
| pct = int((current / total) * 100) |
| label = "Pair" if total > 1 else "Item" |
| st.markdown( |
| f'<div class="progress-wrap">' |
| f'<div class="progress-fill" style="width:{pct}%"></div>' |
| f'</div>' |
| f'<div class="progress-label">{label} {current} of {total}</div>', |
| unsafe_allow_html=True, |
| ) |
|
|
|
|
| |
|
|
| def render_chat_history( |
| turns: list, |
| study_type: str, |
| *, |
| hide_synthetic: bool = True, |
| heading: str | None = None, |
| ) -> None: |
| """ |
| Display conversation turns as styled chat bubbles. |
| |
| Synthetic initial votes use ``vote_response_text`` from lsp (same string the |
| seller model sees). Legacy sessions may still store ``<choice>N</choice>``; |
| those are shown as ``My rating: β¦``. Other user text is shown as-is. |
| |
| When hide_synthetic is False (e.g. transcript review), show scripted user |
| votes readably (choice tag β 'My rating: β¦') but hide the long scripted assistant |
| opening (survey intro + product text) so the overview stays readable. |
| |
| The transcript is rendered in a fixed-height iframe that scrolls to the bottom |
| on each run so the latest model message stays in view. |
| """ |
| labels = PREFERENCE_LABELS if study_type == "preference" else LIKELIHOOD_LABELS |
|
|
| inner = "" |
| if heading: |
| inner += f'<div class="bubble-meta">{_safe(heading)}</div>' |
| for turn in turns: |
| syn = turn.get("synthetic") |
| role = turn.get("role", "") |
| if hide_synthetic and syn: |
| continue |
| if (not hide_synthetic) and syn and role == "assistant": |
| continue |
| content = turn.get("content", "") |
|
|
| if role == "assistant": |
| inner += ( |
| f'<div class="bubble-meta">π€ AI Product Agent</div>' |
| f'<div class="bubble bubble-ai">{_safe(content)}</div>' |
| ) |
| elif role == "user": |
| m = re.match(r"^\s*<choice>(\d+)</choice>\s*$", content.strip()) |
| if m: |
| n = int(m.group(1)) |
| readable = labels.get(n, f"Rating: {n}") |
| display = f"My rating: {readable}" |
| else: |
| display = content |
| inner += ( |
| f'<div class="bubble-meta" style="text-align:right">You</div>' |
| f'<div class="bubble bubble-user">{_safe(display)}</div>' |
| ) |
|
|
| |
| iframe_html = ( |
| f"<style>{_CHAT_SCROLL_AND_BUBBLES_CSS}</style>" |
| f'<div id="study-chat-scroll" class="chat-wrap">{inner}</div>' |
| "<script>" |
| "(function(){" |
| "function sb(){var e=document.getElementById('study-chat-scroll');" |
| "if(e)e.scrollTop=e.scrollHeight;}" |
| "sb();requestAnimationFrame(sb);" |
| "setTimeout(sb,0);setTimeout(sb,80);setTimeout(sb,250);" |
| "})();" |
| "</script>" |
| ) |
| components.html(iframe_html, height=520, scrolling=False) |
|
|
|
|
| |
|
|
| def familiarity_choices(category: str) -> list: |
| """Return the four familiarity options with the used/watched label correct for the category.""" |
| used = FAMILIARITY_USED_LABEL.get(category, "Used it before") |
| return [ |
| "Never heard of it", |
| "Heard of it, but not used/purchased", |
| used, |
| "Purchased it before", |
| ] |
|
|
|
|
| def rating_choices(study_type: str) -> list: |
| """Return Likert options formatted as 'Label (N)' for radio widgets.""" |
| labels = PREFERENCE_LABELS if study_type == "preference" else LIKELIHOOD_LABELS |
| return [f"{v} ({k})" for k, v in labels.items()] |
|
|
|
|
| def parse_rating(choice_str: str) -> int: |
| """Extract integer from a 'Label text (N)' formatted radio-button value.""" |
| try: |
| return int(choice_str.split("(")[-1].rstrip(")")) |
| except Exception: |
| return 4 |