"""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"""
Reasoning
{body}
""" # ---- 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})"