feat: add master notebook library scaffolding and review tooling
Add CLAUDE.md defining the Palladium master notebook conventions and Red Panda Approval criteria, plus a review-notebook slash command for LLM-driven notebook review. Expand .gitignore to block client/engagement documents and generated exports, keeping masters client-clean while allowing text/image sources. Normalize slider widget numeric values from floats to integers in notebook JSON.
This commit is contained in:
208
assessments/CX_AI_Diagnostic/diaglib/visuals.py
Normal file
208
assessments/CX_AI_Diagnostic/diaglib/visuals.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Plotly figure builders — presentation only, consuming engine outputs.
|
||||
|
||||
Chart chrome follows the house dataviz rules (see the repo dataviz
|
||||
reference and docs/brand.md): recessive grid and axes, ink text tokens,
|
||||
fixed entity→color assignments so a color means one thing across every
|
||||
figure, 2px surface gaps between adjacent fills, selective direct labels,
|
||||
one axis per chart. Room-facing: sized and typed to hold attention on a
|
||||
shared screen, not for print.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import plotly.graph_objects as go
|
||||
|
||||
from .models import DiagnosticConfig, ValueAtStake
|
||||
from .value_math import html_money
|
||||
|
||||
# ── Chrome (dataviz reference palette, light surface) ────────────────
|
||||
INK, INK2, MUTED = "#0b0b0b", "#52514e", "#898781"
|
||||
SURFACE, GRID, BASELINE = "#fcfcfb", "#e1e0d9", "#c3c2b7"
|
||||
FONT_STACK = 'system-ui, -apple-system, "Segoe UI", sans-serif'
|
||||
|
||||
# Fixed entity colors — color follows the entity across every figure.
|
||||
THEORETICAL = "#9ec5f4" # light blue: the outer envelope
|
||||
REALIZABLE = "#2a78d6" # blue: what capability can actually capture
|
||||
TRAPPED = "#eda100" # amber: value the capability gap strands
|
||||
COST = "#e34948" # red: unlock investment
|
||||
UNLOCKED = "#1baf7a" # aqua-green: unlock payoff
|
||||
|
||||
# Diverging maturity scale, centered on level 3 — soft poles so ink text
|
||||
# stays readable in every cell (two hues + neutral midpoint, never rainbow).
|
||||
SCORE_SCALE = [
|
||||
(0.0, "#ef8a76"), (0.5, "#f0efe9"), (1.0, "#57c993"),
|
||||
]
|
||||
|
||||
|
||||
def diag_layout(fig: go.Figure, title: str, subtitle: str | None = None,
|
||||
height: int = 440) -> go.Figure:
|
||||
t = f"<b>{title}</b>"
|
||||
if subtitle:
|
||||
t += f"<br><span style='font-size:12px;color:{MUTED}'>{subtitle}</span>"
|
||||
fig.update_layout(
|
||||
title=dict(text=t, font=dict(size=16, color=INK), x=0.02, xanchor="left"),
|
||||
paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,
|
||||
font=dict(family=FONT_STACK, size=13, color=INK2),
|
||||
legend=dict(orientation="h", yanchor="top", y=-0.12, x=0,
|
||||
font=dict(size=11, color=INK2)),
|
||||
height=height, margin=dict(t=70, r=30, b=60, l=70),
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
# ── 1 · Capability heatmap (4 dimensions × 3 competencies) ───────────
|
||||
|
||||
|
||||
def heatmap_fig(grid: dict) -> go.Figure:
|
||||
"""``grid`` comes from scoring.heatmap_grid — pure data in."""
|
||||
n_rows = len(grid["rows"])
|
||||
fig = go.Figure(go.Heatmap(
|
||||
z=grid["z"], text=grid["text"], customdata=grid["hover"],
|
||||
x=list(range(len(grid["cols"]))), y=grid["rows"],
|
||||
zmin=1, zmax=5, colorscale=SCORE_SCALE, showscale=False,
|
||||
texttemplate="%{text}", textfont=dict(size=13, color=INK),
|
||||
hovertemplate="%{customdata}<extra></extra>",
|
||||
xgap=3, ygap=3,
|
||||
))
|
||||
fig.update_xaxes(visible=False)
|
||||
fig.update_yaxes(autorange="reversed", tickfont=dict(size=13, color=INK2),
|
||||
showgrid=False)
|
||||
diag_layout(fig, "Capability heatmap",
|
||||
"12 competencies, levels 1–5 · hover a cell for the evidence",
|
||||
height=90 * n_rows + 120)
|
||||
return fig
|
||||
|
||||
|
||||
# ── 2 · Value at stake (theoretical vs realizable ranges) ────────────
|
||||
|
||||
|
||||
def value_bands_fig(vas: ValueAtStake) -> go.Figure:
|
||||
rows = [
|
||||
("Theoretical value (annual)", vas.theoretical_annual_value_low,
|
||||
vas.theoretical_annual_value_high, THEORETICAL),
|
||||
("Realizable over 18 months", vas.realizable_18mo_low,
|
||||
vas.realizable_18mo_high, REALIZABLE),
|
||||
]
|
||||
fig = go.Figure()
|
||||
for label, low, high, color in rows:
|
||||
fig.add_trace(go.Bar(
|
||||
y=[label], x=[max(high - low, 1)], base=[low], orientation="h",
|
||||
marker=dict(color=color, line=dict(width=2, color=SURFACE)),
|
||||
showlegend=False,
|
||||
hovertemplate=(f"{label}: {html_money(low)} – {html_money(high)}"
|
||||
"<extra></extra>"),
|
||||
))
|
||||
fig.add_annotation(x=high, y=label, xanchor="left", xshift=6,
|
||||
text=f"{html_money(low)} – {html_money(high)}",
|
||||
showarrow=False, font=dict(size=12, color=INK))
|
||||
fig.add_annotation(
|
||||
xref="paper", yref="paper", x=0.02, y=-0.32, xanchor="left",
|
||||
showarrow=False, align="left",
|
||||
text=(f"Trapped by the capability gap: "
|
||||
f"<b>{html_money(vas.trapped_value_low)} – "
|
||||
f"{html_money(vas.trapped_value_high)}</b> per year"),
|
||||
font=dict(size=13, color=INK))
|
||||
fig.update_xaxes(tickformat="$~s", gridcolor=GRID, zeroline=False,
|
||||
tickfont=dict(color=MUTED), rangemode="tozero")
|
||||
fig.update_yaxes(tickfont=dict(size=13, color=INK2), showgrid=False,
|
||||
autorange="reversed") # theoretical on top, then realizable
|
||||
diag_layout(fig, "Value at stake",
|
||||
"ranges, never points · realization capped by the weakest foundation",
|
||||
height=300)
|
||||
fig.update_layout(margin=dict(b=90), bargap=0.5)
|
||||
return fig
|
||||
|
||||
|
||||
# ── 3 · Realizable vs trapped split (scenario-consistent) ────────────
|
||||
|
||||
|
||||
def split_fig(vas: ValueAtStake) -> go.Figure:
|
||||
"""Each scenario bar splits its own theoretical total: realizable
|
||||
run-rate vs trapped, at that scenario's realization factor."""
|
||||
scenarios = [
|
||||
("Conservative", vas.theoretical_annual_value_low, vas.realization_factor_low),
|
||||
("Optimistic", vas.theoretical_annual_value_high, vas.realization_factor_high),
|
||||
]
|
||||
labels = [s[0] for s in scenarios]
|
||||
realizable = [th * r for _, th, r in scenarios]
|
||||
trapped = [th * (1 - r) for _, th, r in scenarios]
|
||||
fig = go.Figure([
|
||||
go.Bar(name="Realizable (annual run-rate)", y=labels, x=realizable,
|
||||
orientation="h",
|
||||
marker=dict(color=REALIZABLE, line=dict(width=2, color=SURFACE)),
|
||||
text=[html_money(v) for v in realizable],
|
||||
textposition="inside", insidetextfont=dict(color="#ffffff"),
|
||||
hovertemplate="Realizable: %{x:$,.0f}<extra>%{y}</extra>"),
|
||||
go.Bar(name="Trapped by capability gap", y=labels, x=trapped,
|
||||
orientation="h",
|
||||
marker=dict(color=TRAPPED, line=dict(width=2, color=SURFACE)),
|
||||
text=[html_money(v) for v in trapped],
|
||||
textposition="inside", insidetextfont=dict(color=INK),
|
||||
hovertemplate="Trapped: %{x:$,.0f}<extra>%{y}</extra>"),
|
||||
])
|
||||
fig.update_layout(barmode="stack", bargap=0.5)
|
||||
fig.update_xaxes(tickformat="$~s", gridcolor=GRID, zeroline=False,
|
||||
tickfont=dict(color=MUTED))
|
||||
fig.update_yaxes(tickfont=dict(size=13, color=INK2), showgrid=False,
|
||||
autorange="reversed") # conservative on top
|
||||
diag_layout(fig, "Where the annual value goes",
|
||||
"each scenario splits its own theoretical total", height=300)
|
||||
fig.update_layout(legend=dict(traceorder="normal"))
|
||||
return fig
|
||||
|
||||
|
||||
# ── 4 · Unlock sequence (cost vs value per move) ─────────────────────
|
||||
|
||||
|
||||
def _move_label(vas_move, config: DiagnosticConfig, idx: int) -> str:
|
||||
"""Compact tick label — full competency names live in the hover and
|
||||
in the on-stage moves table (long joint-lift names don't fit ticks)."""
|
||||
if len(vas_move.competency_ids) == 1:
|
||||
what = config.competency(vas_move.competency_ids[0]).name
|
||||
else:
|
||||
what = f"joint lift ×{len(vas_move.competency_ids)}"
|
||||
return (f"<b>{idx} · {what}</b><br>"
|
||||
f"level {vas_move.current_level} → {vas_move.target_level}")
|
||||
|
||||
|
||||
def unlock_fig(vas: ValueAtStake, config: DiagnosticConfig) -> go.Figure:
|
||||
moves = vas.unlock_sequence
|
||||
labels = [_move_label(m, config, i + 1) for i, m in enumerate(moves)]
|
||||
names = [" + ".join(config.competency(c).name for c in m.competency_ids)
|
||||
for m in moves]
|
||||
fig = go.Figure()
|
||||
fig.add_trace(go.Bar(
|
||||
name="Investment (range)", x=labels,
|
||||
y=[(m.est_cost_high - m.est_cost_low) if m.est_cost_low is not None else 0
|
||||
for m in moves],
|
||||
base=[m.est_cost_low if m.est_cost_low is not None else 0 for m in moves],
|
||||
customdata=[[n, html_money(m.est_cost_low) + " – " + html_money(m.est_cost_high)
|
||||
if m.est_cost_low is not None else "not configured"]
|
||||
for n, m in zip(names, moves)],
|
||||
marker=dict(color=COST, line=dict(width=2, color=SURFACE)),
|
||||
hovertemplate="%{customdata[0]}<br>Investment: %{customdata[1]}<extra></extra>",
|
||||
))
|
||||
fig.add_trace(go.Bar(
|
||||
name="Annual value unlocked (range)", x=labels,
|
||||
y=[m.value_unlocked_high - m.value_unlocked_low for m in moves],
|
||||
base=[m.value_unlocked_low for m in moves],
|
||||
customdata=[[n, html_money(m.value_unlocked_low) + " – "
|
||||
+ html_money(m.value_unlocked_high)]
|
||||
for n, m in zip(names, moves)],
|
||||
marker=dict(color=UNLOCKED, line=dict(width=2, color=SURFACE)),
|
||||
hovertemplate="%{customdata[0]}<br>Unlocked: %{customdata[1]}<extra></extra>",
|
||||
))
|
||||
for i, m in enumerate(moves):
|
||||
if m.est_cost_low is None:
|
||||
fig.add_annotation(x=labels[i], y=0, yanchor="bottom",
|
||||
text="cost not<br>configured", showarrow=False,
|
||||
font=dict(size=11, color=MUTED))
|
||||
fig.update_layout(barmode="group", bargap=0.35, bargroupgap=0.12)
|
||||
fig.update_xaxes(tickfont=dict(size=12, color=INK2), showgrid=False,
|
||||
tickangle=0)
|
||||
fig.update_yaxes(tickformat="$~s", gridcolor=GRID, zerolinecolor=BASELINE,
|
||||
tickfont=dict(color=MUTED))
|
||||
diag_layout(fig, "Unlock sequence",
|
||||
"sequential moves — each is the prerequisite of the next",
|
||||
height=420)
|
||||
return fig
|
||||
Reference in New Issue
Block a user