"""
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''
)
else:
feat_html = ""
else:
feat_html = ""
return (
f''
f'
{lbl_text}{cat_badge}
'
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'{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