""" 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(""" """, 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) -> None: """ Display conversation turns as styled chat bubbles. Synthetic user turns contain a raw N 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 = '
    ' for turn in turns: if turn.get("synthetic"): continue role = turn.get("role", "") content = turn.get("content", "") if role == "assistant": html += ( 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 html += ( f'
    You
    ' f'
    {_safe(display)}
    ' ) html += "
    " 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