feat: add corrected business case page with TEI-styled charts
This commit is contained in:
@@ -29,6 +29,7 @@ import plotly.graph_objects as go
|
||||
import streamlit as st
|
||||
|
||||
import tokencalc.scenarios as tc_scenarios
|
||||
from tokencalc import appendix4 as a4
|
||||
from tokencalc import (
|
||||
CONTRACTED_NAMED_USERS,
|
||||
CTM_DEFAULT_FEATURE_SCOPES,
|
||||
@@ -118,6 +119,7 @@ def _case(scenario: str) -> dict:
|
||||
|
||||
st.sidebar.title("NTT DATA — CTM Token Calculator")
|
||||
page = st.sidebar.radio("Page", [
|
||||
"0. Corrected Business Case",
|
||||
"1. Inputs", "2. Token Meters", "3. Cost Model", "4. Benefit Model",
|
||||
"5. Business Case", "6. Sensitivity Analysis", "7. Export",
|
||||
])
|
||||
@@ -151,9 +153,320 @@ def _users_warning() -> None:
|
||||
)
|
||||
|
||||
|
||||
# ── Corrected-case chart chrome (ports the notebook's TEI styling) ───
|
||||
|
||||
_INK, _INK2, _MUTED = "#0b0b0b", "#52514e", "#898781"
|
||||
_SURFACE, _GRID, _BASELINE = "#fcfcfb", "#e1e0d9", "#c3c2b7"
|
||||
_CUMULATIVE, _CONTEXT = "#52514e", "#c3c2b7"
|
||||
_CAP_COLOR = {
|
||||
"Agent Copilot": "#2a78d6", "WFM": "#1baf7a", "Email": "#eda100",
|
||||
"STA": "#008300", "Predictive Routing": "#4a3aa7",
|
||||
"Supervisor Copilot": "#e34948",
|
||||
}
|
||||
_COST_COLOR = {
|
||||
"CCaaS platform licences (ramp-adjusted)": "#2a78d6",
|
||||
"Base professional services + training": "#1baf7a",
|
||||
"Existing platform (term-contract run-off)": "#eda100",
|
||||
"AI token consumption": "#008300",
|
||||
"AI implementation + KB readiness": "#4a3aa7",
|
||||
"AI steady-state tuning": "#e34948",
|
||||
}
|
||||
_X = [str(y) for y in a4.YEARS]
|
||||
|
||||
|
||||
def _tei_layout(fig, title, subtitle=None, height=460):
|
||||
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='system-ui, -apple-system, "Segoe UI", sans-serif',
|
||||
size=12, color=_INK2),
|
||||
legend=dict(orientation="h", yanchor="top", y=-0.10, x=0,
|
||||
font=dict(size=11, color=_INK2)),
|
||||
xaxis=dict(type="category", showgrid=False, linecolor=_BASELINE,
|
||||
tickfont=dict(color=_MUTED)),
|
||||
yaxis=dict(gridcolor=_GRID, zerolinecolor=_BASELINE, zerolinewidth=1.5,
|
||||
tickformat="$~s", tickfont=dict(color=_MUTED)),
|
||||
hovermode="x unified", bargap=0.45, height=height,
|
||||
margin=dict(t=70, r=30, b=80, l=70),
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
def _bar(x, y, name, color):
|
||||
return go.Bar(x=x, y=y, name=name,
|
||||
marker=dict(color=color, line=dict(width=2, color=_SURFACE)),
|
||||
hovertemplate="%{fullData.name}: %{y:$,.0f}<extra></extra>")
|
||||
|
||||
|
||||
def _cum_line(x, y, name, color=_CUMULATIVE, dash=None):
|
||||
return go.Scatter(x=x, y=y, name=name, mode="lines+markers",
|
||||
line=dict(color=color, width=2, dash=dash),
|
||||
marker=dict(size=8, line=dict(width=2, color=_SURFACE)),
|
||||
hovertemplate="%{fullData.name}: %{y:$,.0f}<extra></extra>")
|
||||
|
||||
|
||||
# ── Page 0: Corrected Business Case ──────────────────────────────────
|
||||
|
||||
if page == "0. Corrected Business Case":
|
||||
st.header("Corrected Business Case — Appendix 4")
|
||||
st.caption(
|
||||
"Genesys's benefits **verbatim** ($15.0M / 3 yr, phased on their own "
|
||||
"deployment schedule) against a cost case corrected for **AI token "
|
||||
"consumption**, **AI implementation effort (V2 LoE)**, **existing-platform "
|
||||
"double-billing**, and the **ramp credit** the deck also missed. "
|
||||
"The sidebar scenario/year controls do not apply to this page. "
|
||||
"Sites and token pricing are shared with the other pages."
|
||||
)
|
||||
_users_warning()
|
||||
|
||||
# ── Controls ─────────────────────────────────────────────────────
|
||||
c1, c2, c3, c4, c5 = st.columns(5)
|
||||
ramp_months = int(c1.number_input(
|
||||
"Ramp (licence-free months)", 0, 24, a4.DEFAULT_RAMP_MONTHS,
|
||||
help="Genesys ramp programme — the $4.3M/yr commit bills from the "
|
||||
"month after the ramp ends."))
|
||||
hours_mode = c2.selectbox("AI impl hours (V2 LoE)", ["low", "mid", "high"],
|
||||
index=1)
|
||||
blended_rate = float(c3.selectbox("Blended rate $/h", [175, 225, 275], index=1))
|
||||
include_kb = c4.toggle("Include KB readiness", value=True,
|
||||
help="500-1,500 h prerequisite project, flagged "
|
||||
"separately from AI implementation.")
|
||||
discount_rate = (a4.TCO_VERBATIM["npv_discount_rate"]
|
||||
if c5.radio("NPV discount", ["13.5% (deck)", "8% (treasury)"],
|
||||
index=0) == "13.5% (deck)" else 0.08)
|
||||
|
||||
with st.expander("Token-model assumptions (🟡 estimated knobs)"):
|
||||
t1, t2, t3 = st.columns(3)
|
||||
copilot_includes_asia = t1.toggle(
|
||||
"Copilot tokens in ASIA", value=False,
|
||||
help="Deck claims $0 Copilot benefit in ASIA — excluded by default "
|
||||
"for apples-to-apples.")
|
||||
na_email_early = t1.toggle(
|
||||
"NA Email implements early (Jan 2027)", value=True,
|
||||
help="NA Gantt exception: Email realizes Apr 2027.")
|
||||
pr_eligibility = t2.slider(
|
||||
"Predictive Routing eligibility", 0.0, 1.0, 1.0, 0.05,
|
||||
help="Share of voice volume on PR-enabled queues. At 100% PR tokens "
|
||||
"(~$1.8M/yr) exceed the $470K/yr claimed benefit.")
|
||||
translate_eligibility = t2.slider(
|
||||
"AI Translate eligibility (SupCopilot proxy)", 0.0, 0.10, 0.01, 0.005)
|
||||
email_tokens_per_msg = t3.number_input(
|
||||
"Email Auto-Respond tokens/msg (🔴 unpublished)", 0.0, 1.0, 0.05, 0.01,
|
||||
help="Working assumption ≈1 AI action per generated response "
|
||||
"(Genesys Cloud Copilot meters 20 AI actions/token).")
|
||||
email_respond_rate = t3.slider(
|
||||
"Email auto-respond rate", 0.0, 0.60, 0.255, 0.005,
|
||||
help="Deck claims 25.5% of email interactions auto-responded.")
|
||||
|
||||
with st.expander("Current-state contracts by region — edit as real data arrives"):
|
||||
st.caption("Seeded as $7.3M × agent share (🟡). Term contracts bill "
|
||||
"through their termination month regardless of Genesys go-live "
|
||||
"— that is the double-billing.")
|
||||
cs_default = a4.current_state_inputs(sites).reset_index()
|
||||
cs_edit = st.data_editor(
|
||||
cs_default[["region", "agents", "annual_cost", "contract_termination"]],
|
||||
key="a4_current_state", hide_index=True,
|
||||
disabled=["region", "agents"],
|
||||
column_config={
|
||||
"annual_cost": st.column_config.NumberColumn(format="$%,.0f"),
|
||||
"contract_termination": st.column_config.DateColumn(),
|
||||
},
|
||||
)
|
||||
current_state = cs_edit.set_index("region")
|
||||
current_state["contract_termination"] = [
|
||||
d if d is not None else a4.DEFAULT_TERMINATION
|
||||
for d in current_state["contract_termination"]
|
||||
]
|
||||
|
||||
# ── Model (all math in tokencalc.appendix4) ──────────────────────
|
||||
token_ro, email_ro, benefit_ro = a4.build_rollouts(
|
||||
sites, na_email_early, ramp_months)
|
||||
benefits_long = a4.benefits_by_year(benefit_ro, na_email_early)
|
||||
benefit_by_year = benefits_long.groupby("year")["benefit"].sum().to_dict()
|
||||
|
||||
core_scopes, email_scopes = a4.build_scopes(
|
||||
sites, copilot_includes_asia, pr_eligibility, translate_eligibility)
|
||||
a4_meters = {**meters,
|
||||
"Email AI (Auto-Respond)": a4.autorespond_meter(email_tokens_per_msg)}
|
||||
tokens_long = a4.token_costs_by_year(
|
||||
sites, a4_meters, pricing, a4.claim_scenario(email_respond_rate),
|
||||
core_scopes, email_scopes, token_ro, email_ro,
|
||||
use_contracted=st.session_state.use_contracted)
|
||||
token_by_year = tokens_long.groupby("year")["annual_cost"].sum().to_dict()
|
||||
|
||||
impl_detail, impl_y, kb_y, steady_y = a4.build_impl_costs(
|
||||
sites, hours_mode, blended_rate, include_kb,
|
||||
copilot_includes_asia, na_email_early)
|
||||
current_y = a4.current_costs_by_year(current_state)
|
||||
licence_y = a4.licence_costs_by_year(ramp_months)
|
||||
ps_y = a4.ps_costs_by_year()
|
||||
|
||||
corrected_costs = pd.DataFrame({
|
||||
"CCaaS platform licences (ramp-adjusted)": licence_y,
|
||||
"Base professional services + training": ps_y,
|
||||
"Existing platform (term-contract run-off)": current_y,
|
||||
"AI token consumption": token_by_year,
|
||||
"AI implementation + KB readiness": {y: impl_y[y] + kb_y[y]
|
||||
for y in a4.YEARS},
|
||||
"AI steady-state tuning": steady_y,
|
||||
}).T[a4.YEARS]
|
||||
corrected_by_year = {y: float(corrected_costs[y].sum()) for y in a4.YEARS}
|
||||
pitched_by_year = {y: a4.TCO_VERBATIM["ccaas_annual"] + ps_y[y] for y in a4.YEARS}
|
||||
|
||||
inc_c, net_c = a4.case_flows(corrected_by_year, benefit_by_year)
|
||||
inc_p, net_p = a4.case_flows(pitched_by_year, benefit_by_year)
|
||||
kpi_c = a4.case_kpis(inc_c, net_c, discount_rate)
|
||||
kpi_p = a4.case_kpis(inc_p, net_p, discount_rate)
|
||||
|
||||
# ── KPIs ─────────────────────────────────────────────────────────
|
||||
m1, m2, m3, m4, m5 = st.columns(5)
|
||||
m1.metric("3-yr net (corrected)", a4.money(kpi_c["net_3yr"]),
|
||||
delta=a4.money(kpi_c["net_3yr"] - kpi_p["net_3yr"]) + " vs pitch",
|
||||
delta_color="inverse")
|
||||
m2.metric("ROI", f"{kpi_c['roi']:.0%}" if kpi_c["roi"] is not None
|
||||
else "n/a — net saving")
|
||||
m3.metric(f"NPV @ {discount_rate:.1%}", a4.money(kpi_c["npv"]))
|
||||
m4.metric("Payback", kpi_c["payback"])
|
||||
m5.metric("3-yr programme cost", a4.money(sum(corrected_by_year.values())),
|
||||
delta=a4.money(sum(corrected_by_year.values())
|
||||
- sum(pitched_by_year.values())) + " vs pitch",
|
||||
delta_color="inverse")
|
||||
|
||||
smell = sum(impl_y.values()) / a4.SLIDE_TOTALS["total_3yr"]
|
||||
if smell < a4.SMELL_TEST_FLOOR:
|
||||
st.caption(f"⚠️ Smell test: AI implementation = {smell:.1%} of the benefit "
|
||||
f"claim, below the 15% floor (industry band 20-40%). V2 "
|
||||
f"deliberately strips vendor inflation — sweep hours × rate "
|
||||
f"above to test robustness.")
|
||||
|
||||
# ── Figures ──────────────────────────────────────────────────────
|
||||
tab_bc, tab_ben, tab_cost, tab_cmp = st.tabs(
|
||||
["Business case", "Benefits (verbatim)", "Costs (corrected)",
|
||||
"Pitched vs corrected"])
|
||||
|
||||
with tab_bc:
|
||||
fig = go.Figure()
|
||||
fig.add_trace(_bar(_X, [benefit_by_year[y] for y in a4.YEARS],
|
||||
"Benefits (verbatim Genesys)", "#2a78d6"))
|
||||
fig.add_trace(_bar(_X, [-inc_c[y] for y in a4.YEARS],
|
||||
"Incremental cost vs $7.3M/yr baseline", "#e34948"))
|
||||
cum_net = pd.Series([net_c[y] for y in a4.YEARS]).cumsum()
|
||||
fig.add_trace(_cum_line(_X, cum_net, "Cumulative net"))
|
||||
for i, c in enumerate(cum_net):
|
||||
fig.add_annotation(x=i, y=float(c), text=f"<b>{a4.money(float(c))}</b>",
|
||||
showarrow=False, yshift=14 if c >= 0 else -14,
|
||||
font=dict(size=12, color=_INK))
|
||||
fig.update_layout(barmode="relative")
|
||||
_tei_layout(fig, "Corrected business case — benefits vs incremental cost",
|
||||
"Baseline = keep paying $7.3M/yr · double-billing hits 2026-27, "
|
||||
"cost avoidance and benefits land 2028", height=500)
|
||||
st.plotly_chart(fig, width="stretch", key="a4_fig_case")
|
||||
|
||||
with tab_ben:
|
||||
fig = go.Figure()
|
||||
for cap in a4.CAPABILITIES:
|
||||
vals = [benefits_long.query("capability == @cap and year == @y")
|
||||
["benefit"].sum() for y in a4.YEARS]
|
||||
fig.add_trace(_bar(_X, vals, cap, _CAP_COLOR[cap]))
|
||||
cum = pd.Series([benefit_by_year[y] for y in a4.YEARS]).cumsum()
|
||||
fig.add_trace(_cum_line(_X, cum, "Cumulative benefits"))
|
||||
for i, y in enumerate(a4.YEARS):
|
||||
fig.add_annotation(x=i, y=benefit_by_year[y],
|
||||
text=f"<b>{a4.money(benefit_by_year[y])}</b>",
|
||||
showarrow=False, yshift=12,
|
||||
font=dict(size=12, color=_INK))
|
||||
fig.update_layout(barmode="stack")
|
||||
_tei_layout(fig, "Benefits over 3 years — verbatim Genesys (Appendix 4)",
|
||||
"Phased by Genesys's own deployment schedule — $0 in 2026 · "
|
||||
"WFM = Workforce Forecast & Scheduling")
|
||||
st.plotly_chart(fig, width="stretch", key="a4_fig_benefits")
|
||||
|
||||
with tab_cost:
|
||||
fig = go.Figure()
|
||||
for line in corrected_costs.index:
|
||||
fig.add_trace(_bar(_X, [corrected_costs.loc[line, y] for y in a4.YEARS],
|
||||
line, _COST_COLOR[line]))
|
||||
cum_c = pd.Series([corrected_by_year[y] for y in a4.YEARS]).cumsum()
|
||||
cum_p = pd.Series([pitched_by_year[y] for y in a4.YEARS]).cumsum()
|
||||
fig.add_trace(_cum_line(_X, cum_c, "Cumulative — corrected"))
|
||||
fig.add_trace(_cum_line(_X, cum_p, "Cumulative — as pitched",
|
||||
color=_CONTEXT, dash="dash"))
|
||||
for i, y in enumerate(a4.YEARS):
|
||||
fig.add_annotation(x=i, y=corrected_by_year[y],
|
||||
text=f"<b>{a4.money(corrected_by_year[y])}</b>",
|
||||
showarrow=False, yshift=12,
|
||||
font=dict(size=12, color=_INK))
|
||||
delta = float(cum_c.iloc[-1] - cum_p.iloc[-1])
|
||||
fig.add_annotation(x=len(a4.YEARS) - 1, y=float(cum_c.iloc[-1]),
|
||||
text=f"3-yr <b>{a4.html_money(float(cum_c.iloc[-1]))}</b> — "
|
||||
f"{a4.html_money(delta)} above the pitch",
|
||||
showarrow=False, yshift=18, xshift=-70,
|
||||
font=dict(size=12, color=_INK2))
|
||||
fig.update_layout(barmode="stack")
|
||||
_tei_layout(fig, "Programme cost over 3 years — with the missed costs",
|
||||
"Existing platforms bill until term-contract end "
|
||||
"(double-billing) · licences ramp-free · tokens + AI "
|
||||
"implementation added", height=500)
|
||||
st.plotly_chart(fig, width="stretch", key="a4_fig_costs")
|
||||
|
||||
with tab_cmp:
|
||||
fig = go.Figure()
|
||||
fig.add_trace(_bar(_X, [pitched_by_year[y] for y in a4.YEARS],
|
||||
"As pitched (deck)", _CONTEXT))
|
||||
fig.add_trace(_bar(_X, [corrected_by_year[y] for y in a4.YEARS],
|
||||
"Corrected", "#2a78d6"))
|
||||
for i, y in enumerate(a4.YEARS):
|
||||
d = corrected_by_year[y] - pitched_by_year[y]
|
||||
fig.add_annotation(x=i, y=corrected_by_year[y], xshift=16,
|
||||
text=f"<b>{'+' if d >= 0 else '−'}{a4.money(abs(d))}</b>",
|
||||
showarrow=False, yshift=12,
|
||||
font=dict(size=12, color=_INK))
|
||||
fig.update_layout(barmode="group", bargap=0.35, bargroupgap=0.15)
|
||||
_tei_layout(fig, "Cost case: as pitched vs corrected, by year",
|
||||
"Delta labels = what each year's pitch understates", height=400)
|
||||
st.plotly_chart(fig, width="stretch", key="a4_fig_compare")
|
||||
|
||||
# ── Detail tables ────────────────────────────────────────────────
|
||||
with st.expander("Cost stack detail"):
|
||||
show = corrected_costs.copy()
|
||||
show.columns = [str(c) for c in show.columns]
|
||||
show["3-yr"] = show.sum(axis=1)
|
||||
show.loc["TOTAL — corrected"] = show.sum()
|
||||
st.dataframe(show, width="stretch",
|
||||
column_config={c: st.column_config.NumberColumn(
|
||||
str(c), format="$%,.0f") for c in show.columns})
|
||||
with st.expander("Token consumption detail"):
|
||||
tok = tokens_long.pivot_table(index="cost_line", columns="year",
|
||||
values="annual_cost", aggfunc="sum")
|
||||
tok.columns = [str(c) for c in tok.columns]
|
||||
tok.loc["WFM (no token meter — licence-included)"] = 0.0
|
||||
tok["3-yr"] = tok.sum(axis=1)
|
||||
tok = tok.sort_values("3-yr", ascending=False)
|
||||
tok.loc["TOTAL"] = tok.sum()
|
||||
st.dataframe(tok, width="stretch",
|
||||
column_config={c: st.column_config.NumberColumn(
|
||||
str(c), format="$%,.0f") for c in tok.columns})
|
||||
with st.expander("AI implementation detail (V2 LoE)"):
|
||||
impl_show = impl_detail.copy()
|
||||
impl_show.columns = [str(c) for c in impl_show.columns]
|
||||
st.dataframe(impl_show, width="stretch", hide_index=True,
|
||||
column_config={str(c): st.column_config.NumberColumn(
|
||||
str(c), format="$%,.0f")
|
||||
for c in ["cost", *a4.YEARS]})
|
||||
|
||||
st.caption(
|
||||
"Not modelled: early-termination fees, migration costs beyond PS/impl. "
|
||||
"Non-NAM site volumes are tokencalc placeholders (🟡). Full method, "
|
||||
"assertions and sensitivity grids: "
|
||||
"`notebooks/ctm_business_case_corrected.ipynb`."
|
||||
)
|
||||
|
||||
# ── Page 1: Inputs ───────────────────────────────────────────────────
|
||||
|
||||
if page == "1. Inputs":
|
||||
elif page == "1. Inputs":
|
||||
st.header("Inputs")
|
||||
st.caption("Site data outside NAM is **estimated — confirm with CTM data**.")
|
||||
_users_warning()
|
||||
@@ -223,7 +536,9 @@ if page == "1. Inputs":
|
||||
with col2:
|
||||
up = st.file_uploader("Load scenario JSON", type="json")
|
||||
if up is not None and st.button("Load"):
|
||||
s, t, sc = scenario_state_from_json(up.read().decode())
|
||||
# 4th element is the rollout plan (None for legacy files) —
|
||||
# not used by these pages yet.
|
||||
s, t, sc, _rollout = scenario_state_from_json(up.read().decode())
|
||||
st.session_state.sites, st.session_state.takeouts = s, t
|
||||
st.session_state.scopes = sc
|
||||
st.cache_data.clear()
|
||||
|
||||
Reference in New Issue
Block a user