""" 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 # Shared by inject_css (main page) and the chat iframe so bubbles match. _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; } """ # ── Global CSS ──────────────────────────────────────────────────────────────── def inject_css() -> None: st.markdown( """ """, 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'' f'{_safe(CATEGORY_DISPLAY.get(category, category))}' f'' 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'
' f'
Description
' f'
{_safe(desc_text)}
' f'
' ) 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"
  • {_safe(f)}
  • " for f in feat_items) feat_html = ( f'
    ' f'
    Features
    ' f'' f'
    ' ) else: feat_html = "" else: feat_html = "" return ( f'
    ' f'
    {lbl_text}{cat_badge}
    ' f'
    ' f'
    {title}
    ' f'
    {price_str}
    ' f'
    ' f'{desc_html}{feat_html}' f'
    ' ) 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) + '
    — VS —
    ' + _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'
    ' f'
    ' f'
    ' f'
    {label} {current} of {total}
    ', unsafe_allow_html=True, ) # ── Chat bubble renderer ────────────────────────────────────────────────────── 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 ``N``; 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'
    {_safe(heading)}
    ' 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'
    🤖 AI Product Agent
    ' f'
    {_safe(content)}
    ' ) elif role == "user": m = re.match(r"^\s*(\d+)\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'
    You
    ' f'
    {_safe(display)}
    ' ) # Iframe + JS scroll-to-bottom so the latest model reply is visible after each rerun. iframe_html = ( f"" f'
    {inner}
    ' "" ) components.html(iframe_html, height=520, scrolling=False) # ── 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