"""HTML rendering for the Co-Pilot demo.
Each function takes typed data (a CastMember, a CopilotResult, etc.)
and returns a self-contained HTML snippet. Gradio injects the snippets
into `gr.HTML` components.
Visual discipline:
- Single typeface (Inter for body, JetBrains Mono for numbers).
- Three colors only: page background near-white, near-black ink,
one accent color per class (green/yellow/red). No gradient palettes,
no shadows, no neon. Liquid demo aesthetic.
- Inline styles only (no external CSS file). The renderer is one
self-contained module so a future maintainer can edit visuals
without touching the inference path.
The renderer NEVER reaches into model internals. It consumes the
serializable result objects produced by `copilot_inference.py`.
"""
from __future__ import annotations
import html
from typing import Iterable
from encoder.src.demo.copilot_inference import (
LABEL_COLORS,
LABEL_NAMES,
CastMember,
CopilotResult,
)
# ---- shared constants ----
_PAGE_BG = "#fafafa"
_INK = "#171717"
_INK_DIM = "#525252"
_BORDER = "rgba(0,0,0,0.08)"
_CARD_BG = "#ffffff"
_TONE_COLORS: dict[str, str] = {
"angry": "#ef4444",
"confused": "#a855f7",
"casual": "#3b82f6",
"formal": "#0ea5e9",
"terse": "#737373",
}
# ---- cast strip ----
def render_cast_strip(cast: list[CastMember], selected_idx: int) -> str:
"""Six clickable archetype cards across the top.
The card buttons are styled as anchors but the *click* is wired
by Gradio via separate `gr.Button` components — this function
only renders the visual chrome that mirrors the selected state.
Selection is driven by the `selected_idx` argument.
"""
cards: list[str] = []
for i, m in enumerate(cast):
is_sel = i == selected_idx
accent = LABEL_COLORS[m.expected_label]
border = accent if is_sel else _BORDER
bg = "#ffffff" if not is_sel else _hex_with_alpha(accent, 0.06)
cards.append(f"""
{html.escape(m.display_name)}
●
expected: {html.escape(_short_label(m.expected_label_name))}
""")
return f"""
{''.join(cards)}
"""
# ---- complaint quote ----
def render_complaint(member: CastMember) -> str:
"""Large pull-quote of what the customer wrote."""
tone_color = _TONE_COLORS.get(member.tone, _INK_DIM)
return f"""
Complaint received · tone:
{html.escape(member.tone)}
“{html.escape(member.complaint_text)}”
"""
# ---- timeline ----
def render_timeline(
disputed_idx: int,
top_k_positions: Iterable[int] | None = None,
attribution_probs: Iterable[float] | None = None,
num_positions: int = 64,
) -> str:
"""64 dots; the disputed one is a red star, top-k are accent glows.
If `top_k_positions` is None (pre-inference), all dots are neutral
except the disputed star. If `attribution_probs` is provided, dot
opacity scales with per-position attribution probability so the
cluster around the highlighted positions reads as a glow gradient.
"""
# Explicit None check: numpy arrays raise ValueError under `or []`.
top_k_set: set[int] = set()
if top_k_positions is not None:
top_k_set = {int(i) for i in top_k_positions}
probs = list(attribution_probs) if attribution_probs is not None else None
dots: list[str] = []
for i in range(num_positions):
if i == disputed_idx:
dots.append(_dot_disputed(i))
elif i in top_k_set:
alpha = float(probs[i]) if probs is not None else 1.0
dots.append(_dot_attributed(i, alpha))
else:
faint = float(probs[i]) * 0.4 if probs is not None else 0.0
dots.append(_dot_neutral(i, faint))
return f"""
64-transaction history · oldest → newest
★ disputed
● contributed
{''.join(dots)}
"""
def _dot_disputed(i: int) -> str:
return f"""
★
"""
def _dot_attributed(i: int, alpha: float) -> str:
glow = max(0.4, min(1.0, alpha))
return f"""
"""
def _dot_neutral(i: int, faint: float = 0.0) -> str:
bg = f"rgba(245, 158, 11, {faint:.2f})" if faint > 0.0 else "rgba(0,0,0,0.18)"
return f"""
"""
# ---- score panel ----
def render_score(result: CopilotResult | None) -> str:
"""Big number, color band, class label.
When `result` is None (idle / pre-click), shows a placeholder
that occupies the same vertical space so the layout doesn't jump
when the score arrives.
"""
if result is None:
return _score_placeholder()
pct = int(round(result.score * 100))
color = result.color
label = result.predicted_label
return f"""
Model verdict
0.{pct:02d}
{html.escape(label)}
"""
def _score_placeholder() -> str:
return f"""
Model verdict
0.—
Awaiting analysis · click Analyze
"""
# ---- reasoning panel (streams in) ----
def render_reasoning(text: str | None) -> str:
"""Model's LM-decoded reasoning, with a blinking caret while streaming."""
if text is None:
body = f"""
Reasoning will appear here once the model has read the customer's history.
"""
else:
body = f"""
{html.escape(text)}
"""
return f"""
"""
# ---- header ----
def render_header() -> str:
return f"""
Liquid AI · LFM2.5-350M backbone · encoder + LoRA
Dispute Co-Pilot
"""
# ---- helpers ----
def _short_label(name: str) -> str:
return {
"unlikely_friendly_fraud": "likely legit",
"ambiguous": "ambiguous",
"likely_friendly_fraud": "likely fraud",
}.get(name, name)
def _hex_with_alpha(hex_color: str, alpha: float) -> str:
"""Convert #RRGGBB + alpha float into an rgba() string."""
h = hex_color.lstrip("#")
if len(h) != 6:
return hex_color
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return f"rgba({r}, {g}, {b}, {alpha:.3f})"