"""HTML rendering for the Collections Co-Pilot demo.
Mirror of `copilot_render.py` (dispute) adapted to the K-treatment
output. Same visual vocabulary as dispute:
- Inter for body, JetBrains Mono for numbers.
- Subtle palette: near-white background, near-black ink, small set
of brand accents per treatment.
- Inline styles only; self-contained snippets.
The unique panel for Collections is the treatment-grid score panel:
four horizontal bars showing P(likely_respond) per treatment, with
the dominant treatment highlighted with a recommendation pill.
"""
from __future__ import annotations
import html
from typing import Iterable
import numpy as np
from encoder.src.data.synthetic_collections import (
BAND_NAMES,
NUM_TREATMENTS,
TREATMENT_NAMES,
)
from encoder.src.demo.copilot_inference_collections import (
TREATMENT_COLORS,
CollectionsCastMember,
CollectionsResult,
)
# ---- shared constants ----
_PAGE_BG = "#fafafa"
_INK = "#171717"
_INK_DIM = "#525252"
_BORDER = "rgba(0,0,0,0.08)"
_CARD_BG = "#ffffff"
_BAND_NAME_SHORT = {
"unlikely_respond": "unlikely",
"ambiguous": "ambiguous",
"likely_respond": "LIKELY",
}
# ---- header ----
def render_header() -> str:
return f"""
Liquid AI · LFM2.5-350M backbone · encoder + LoRA
Collections Co-Pilot
"""
# ---- cast strip ----
def render_cast_strip(
cast: list[CollectionsCastMember],
selected_idx: int,
) -> str:
"""Six clickable cast cards across the top.
Each card shows the cast member's display name and a small pill
indicating the expected dominant treatment.
"""
cards: list[str] = []
for i, m in enumerate(cast):
is_sel = i == selected_idx
accent = TREATMENT_COLORS[m.dominant_treatment]
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(m.dominant_treatment_name)}
""")
return f"""
{''.join(cards)}
"""
# ---- context text ----
def render_context(member: CollectionsCastMember) -> str:
"""Pull-quote of the analyst-facing delinquency context."""
return f"""
Delinquency context · pattern:
{html.escape(member.pattern)}
“{html.escape(member.context_text)}”
{html.escape(member.description)}
"""
# ---- timeline ----
def render_timeline(
context_idx: int,
top_k_positions: Iterable[int] | None = None,
attribution_probs: Iterable[float] | None = None,
num_positions: int = 64,
) -> str:
"""64 dots; the context position is starred, top-k glow.
Same dot vocabulary as the dispute timeline — re-using the visual
grammar so audiences cross-trained on Dispute pick up Collections
in one frame.
"""
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 == context_idx:
dots.append(_dot_context(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
★ now
● contributed
{''.join(dots)}
"""
def _dot_context(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"""
"""
# ---- treatment grid (the unique Collections panel) ----
def render_treatment_grid(result: CollectionsResult | None) -> str:
"""K horizontal bars showing P(likely_respond) per treatment.
The dominant treatment is highlighted with a pill. When `result`
is None (pre-analyze), renders a placeholder so the layout doesn't
shift when the verdict arrives.
"""
if result is None:
return _treatment_grid_placeholder()
rows: list[str] = []
for t in range(NUM_TREATMENTS):
score = float(result.likely_scores[t])
band = result.predicted_bands[t]
accent = TREATMENT_COLORS[t]
is_dominant = t == result.dominant_treatment
bar_w = max(2, int(score * 100))
rows.append(f"""
{'★ ' if is_dominant else ' '}{html.escape(TREATMENT_NAMES[t])}
{score:.2f}
{html.escape(_BAND_NAME_SHORT.get(BAND_NAMES[band], BAND_NAMES[band]))}
""")
dom_idx = result.dominant_treatment
dom_color = TREATMENT_COLORS[dom_idx]
dom_score = float(result.likely_scores[dom_idx])
dom_name = TREATMENT_NAMES[dom_idx]
return f"""
Treatment scoreboard
recommend: {html.escape(dom_name)} · {dom_score:.2f}
{''.join(rows)}
"""
def _treatment_grid_placeholder() -> str:
return f"""
Treatment scoreboard
Click Analyze to see per-treatment response probabilities.
"""
# ---- reasoning ----
def render_reasoning(text: str | None) -> str:
"""Reasoning panel; same vocabulary as dispute."""
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"""
"""
# ---- helpers ----
def _hex_with_alpha(hex_color: str, alpha: float) -> str:
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})"