"""
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''
)
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,
*,
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