Spaces:
Running
Running
| """ | |
| 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 | |
| from src.config import CATEGORY_DISPLAY, FAMILIARITY_USED_LABEL, LIKELIHOOD_LABELS, PREFERENCE_LABELS | |
| # ββ Global CSS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def inject_css() -> None: | |
| st.markdown(""" | |
| <style> | |
| #MainMenu, footer, header { visibility: hidden; } | |
| .block-container { max-width: 860px; padding-top: 2rem; } | |
| /* ββ 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; } | |
| /* ββ Chat bubbles ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .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; /* force dark text regardless of theme */ | |
| } | |
| .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; | |
| } | |
| /* ββ 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) | |
| # ββ HTML safety βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _safe(text) -> str: | |
| """Escape user-supplied text for safe embedding in HTML attributes and content.""" | |
| s = _html.unescape(str(text)) | |
| # Lightly normalise run-on sentences common in Amazon descriptions | |
| s = re.sub(r"([.!?:])([A-Z])", r"\1 \2", s) | |
| s = _html.escape(s) | |
| # Escape markdown characters that Streamlit might render | |
| for ch in ["*", "_", "~", "`", "[", "]"]: | |
| s = s.replace(ch, f"&#{ord(ch)};") | |
| return s.replace("\n", " ") | |
| # ββ Product card HTML βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 "" | |
| ) | |
| # Description | |
| 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 = "" | |
| # Features | |
| 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) | |
| # ββ Progress bar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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, | |
| ) | |
| # ββ Chat bubble renderer ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def render_chat_history(turns: list, study_type: str) -> None: | |
| """ | |
| Display conversation turns as styled chat bubbles. | |
| Synthetic user turns contain a raw <choice>N</choice> tag β these are | |
| translated to a human-readable label (e.g. "My rating: Neutral") for display. | |
| The raw tag is preserved in state for the model; only the display changes. | |
| """ | |
| labels = PREFERENCE_LABELS if study_type == "preference" else LIKELIHOOD_LABELS | |
| html = '<div class="chat-wrap">' | |
| for turn in turns: | |
| if turn.get("synthetic"): | |
| continue | |
| role = turn.get("role", "") | |
| content = turn.get("content", "") | |
| if role == "assistant": | |
| html += ( | |
| 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 | |
| html += ( | |
| f'<div class="bubble-meta" style="text-align:right">You</div>' | |
| f'<div class="bubble bubble-user">{_safe(display)}</div>' | |
| ) | |
| html += "</div>" | |
| st.markdown(html, unsafe_allow_html=True) | |
| # ββ Rating / familiarity helpers ββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 # neutral fallback |