feat: add corrected business case page with TEI-styled charts

This commit is contained in:
2026-07-07 11:48:52 -04:00
parent 483bbe61dc
commit 0fa2091a25
4 changed files with 1268 additions and 447 deletions

View File

@@ -29,6 +29,7 @@ import plotly.graph_objects as go
import streamlit as st import streamlit as st
import tokencalc.scenarios as tc_scenarios import tokencalc.scenarios as tc_scenarios
from tokencalc import appendix4 as a4
from tokencalc import ( from tokencalc import (
CONTRACTED_NAMED_USERS, CONTRACTED_NAMED_USERS,
CTM_DEFAULT_FEATURE_SCOPES, CTM_DEFAULT_FEATURE_SCOPES,
@@ -118,6 +119,7 @@ def _case(scenario: str) -> dict:
st.sidebar.title("NTT DATA — CTM Token Calculator") st.sidebar.title("NTT DATA — CTM Token Calculator")
page = st.sidebar.radio("Page", [ page = st.sidebar.radio("Page", [
"0. Corrected Business Case",
"1. Inputs", "2. Token Meters", "3. Cost Model", "4. Benefit Model", "1. Inputs", "2. Token Meters", "3. Cost Model", "4. Benefit Model",
"5. Business Case", "6. Sensitivity Analysis", "7. Export", "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 ─────────────────────────────────────────────────── # ── Page 1: Inputs ───────────────────────────────────────────────────
if page == "1. Inputs": elif page == "1. Inputs":
st.header("Inputs") st.header("Inputs")
st.caption("Site data outside NAM is **estimated — confirm with CTM data**.") st.caption("Site data outside NAM is **estimated — confirm with CTM data**.")
_users_warning() _users_warning()
@@ -223,7 +536,9 @@ if page == "1. Inputs":
with col2: with col2:
up = st.file_uploader("Load scenario JSON", type="json") up = st.file_uploader("Load scenario JSON", type="json")
if up is not None and st.button("Load"): 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.sites, st.session_state.takeouts = s, t
st.session_state.scopes = sc st.session_state.scopes = sc
st.cache_data.clear() st.cache_data.clear()

View File

@@ -0,0 +1,108 @@
"""Appendix-4 corrected business case — hand-check acceptance numbers."""
from __future__ import annotations
import datetime as dt
import math
import pytest
from tokencalc import appendix4 as a4
from tokencalc.defaults import CTM_DEFAULT_SITES, DEFAULT_METERS, DEFAULT_PRICING
SITES = list(CTM_DEFAULT_SITES)
def test_verbatim_crossfoots_to_slide_totals():
df = a4.verbatim_dataframe()
for r, expect in a4.SLIDE_TOTALS["regional_3yr"].items():
got = df.loc[df.region == r, "three_yr"].sum()
assert abs(got - expect) <= a4.crossfoot_tolerance(expect), r
for c, expect in a4.SLIDE_TOTALS["capability_3yr"].items():
got = df.loc[df.capability == c, "three_yr"].sum()
assert abs(got - expect) <= a4.crossfoot_tolerance(expect), c
assert abs(df["three_yr"].sum() - a4.SLIDE_TOTALS["total_3yr"]) <= \
a4.crossfoot_tolerance(a4.SLIDE_TOTALS["total_3yr"])
def test_benefits_phase_on_the_deck_schedule():
_, _, benefit_rollout = a4.build_rollouts(SITES)
long = a4.benefits_by_year(benefit_rollout)
by_year = long.groupby("year")["benefit"].sum()
assert by_year[2026] == 0.0, "2026 must be $0 under Genesys's own schedule"
# Scaling at the finest grain reproduces every verbatim 3-yr value exactly.
for (region, cap), (_, three_yr) in a4.VERBATIM_BENEFITS.items():
got = long.query("region == @region and capability == @cap")["benefit"].sum()
assert got == pytest.approx(three_yr)
def test_ramp_zeroes_year_one_licences():
assert a4.licence_costs_by_year(12) == {2026: 0.0, 2027: 4_300_000.0,
2028: 4_300_000.0}
assert a4.licence_costs_by_year(0)[2026] == 4_300_000.0
assert a4.licence_costs_by_year(18)[2027] == pytest.approx(4_300_000 * 6 / 12)
def test_current_state_run_off():
cs = a4.current_state_inputs(SITES)
assert cs["annual_cost"].sum() == pytest.approx(7_300_000)
by_year = a4.current_costs_by_year(cs)
assert by_year == {2026: pytest.approx(7_300_000),
2027: pytest.approx(7_300_000), 2028: 0.0}
cs.loc["NA", "contract_termination"] = dt.date(2028, 6, 30)
assert a4.current_costs_by_year(cs)[2028] == pytest.approx(
cs.loc["NA", "annual_cost"] * 6 / 12)
def test_token_hand_checks():
token_ro, email_ro, _ = a4.build_rollouts(SITES)
core, email = a4.build_scopes(SITES, copilot_includes_asia=False)
meters = {**DEFAULT_METERS, "Email AI (Auto-Respond)": a4.autorespond_meter(0.05)}
long = a4.token_costs_by_year(SITES, meters, DEFAULT_PRICING,
a4.claim_scenario(0.255), core, email,
token_ro, email_ro)
# STA 2028: NAM/AUZ/EMEA × 12 months + ASIA × 10 months, by hand.
sta = long.query("cost_line == 'Speech & Text Analytics [named]'")
assert sta.query("year == 2028")["annual_cost"].sum() == pytest.approx(715_800)
# Agent Copilot 2028 (ASIA off): 1,490 users × 40 tokens × 12 months.
cp = long.query("cost_line == 'Agent Copilot [named]' and year == 2028")
assert cp["annual_cost"].sum() == pytest.approx(1_490 * 40 * 12)
# Rule 1: Copilot covers AI Summary at Copilot sites.
assert (long.query("cost_line == 'AI Summary & Insights'")["annual_cost"] == 0).all()
# Nothing is live in 2026.
assert long.query("year == 2026")["annual_cost"].sum() == 0
# PR NAM steady-month tokens.
assert math.ceil(
1_214_358 * DEFAULT_METERS["Predictive Routing"].tokens_per_unit) == 71_433
def test_impl_costs_reconcile_with_v2_doc():
_, impl_y, kb_y, steady_y = a4.build_impl_costs(SITES, "mid", 225.0,
include_kb=True)
assert sum(impl_y.values()) == pytest.approx(5_850 * 225) # V2's "$1.3M"
assert sum(kb_y.values()) == pytest.approx(1_000 * 225)
assert steady_y == {2026: 0.0, 2027: pytest.approx(700 * 225),
2028: pytest.approx(700 * 225)}
# Impl spend is fully booked by each region's implementation month.
assert a4.impl_year_fractions(18) == pytest.approx([12 / 18, 6 / 18, 0.0])
assert a4.impl_year_fractions(27) == pytest.approx([12 / 27, 12 / 27, 3 / 27])
def test_case_flows_and_kpis():
benefits = {2026: 0.0, 2027: 2_000_000.0, 2028: 12_000_000.0}
costs = {2026: 10_000_000.0, 2027: 13_000_000.0, 2028: 8_000_000.0}
inc, net = a4.case_flows(costs, benefits)
assert inc == {2026: pytest.approx(2_700_000),
2027: pytest.approx(5_700_000),
2028: pytest.approx(700_000)}
for y in a4.YEARS:
assert net[y] == pytest.approx(benefits[y] - inc[y])
kpis = a4.case_kpis(inc, net)
assert kpis["benefits_3yr"] == pytest.approx(sum(benefits.values()))
assert kpis["net_3yr"] == pytest.approx(sum(net.values()))
assert kpis["roi"] == pytest.approx(kpis["net_3yr"] / kpis["incremental_cost_3yr"])
assert kpis["discount_rate"] == 0.135
# Net cost saving → ROI undefined.
inc2 = {y: -1.0 for y in a4.YEARS}
net2 = {y: benefits[y] + 1.0 for y in a4.YEARS}
assert a4.case_kpis(inc2, net2)["roi"] is None

View File

@@ -0,0 +1,515 @@
"""
Appendix-4 corrected business case — the Genesys/Broadreach benefits
kept verbatim, with the costs the deck omitted: AI Experience token
consumption, AI implementation effort (V2 LoE), and double-billing of
the existing platforms until their term contracts end.
Single source of truth shared by the notebook
(``notebooks/ctm_business_case_corrected.ipynb``) and the Streamlit
"Corrected Business Case" view — presentation layers hold no math.
Sources: ``docs/Appendix 4 - CCaaS Platform Benefit Calculations
(Consolidated).pptx`` (verbatim figures, deployment schedule) and
``docs/ctm_ai_labour_estimate_V2.md`` (implementation hours).
"""
from __future__ import annotations
import dataclasses
import datetime as dt
import math
import pandas as pd
from .business_case import npv, payback_years
from .cost_model import calculate_total_cost
from .defaults import DEFAULT_METERS
from .inputs import FeatureScope, SiteInput
from .meters import Confidence, TokenMeter, TokenPricing
from .rollout import RolloutPlan
from .scenarios import Scenario
# ── Timeline ─────────────────────────────────────────────────────────
YEARS = [2026, 2027, 2028] # model years 1..3, contract start Jan 2026
YEAR_INDEX = {2026: 1, 2027: 2, 2028: 3}
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
def month_label(m: int) -> str:
"""Calendar label for a 1-indexed month from Jan 2026 (m=21 → 'Sep 2027')."""
return f"{_MONTHS[(m - 1) % 12]} {2026 + (m - 1) // 12}"
# ── Verbatim Appendix 4 figures ──────────────────────────────────────
REGIONS = ["NA", "ANZ", "EMEA", "ASIA"]
CAPABILITIES = ["Agent Copilot", "WFM", "Email", "STA",
"Predictive Routing", "Supervisor Copilot"]
#: (annual_value, three_yr_value) — VERBATIM slides 12-15, do not edit.
VERBATIM_BENEFITS: dict[tuple[str, str], tuple[float, float]] = {
("NA", "Agent Copilot"): (2_400_000, 3_400_000),
("NA", "Email"): (1_900_000, 2_500_000),
("NA", "STA"): (294_000, 506_000),
("NA", "Supervisor Copilot"): (218_000, 291_000),
("NA", "Predictive Routing"): (97_000, 167_000),
("NA", "WFM"): (0, 0), # NA excluded — has similar feature
("ANZ", "Agent Copilot"): (3_600_000, 3_900_000),
("ANZ", "WFM"): (1_300_000, 1_400_000),
("ANZ", "Predictive Routing"): (279_000, 302_000),
("ANZ", "Email"): (132_000, 143_000),
("ANZ", "STA"): (97_000, 105_000),
("ANZ", "Supervisor Copilot"): (25_000, 27_000),
("ASIA", "WFM"): (1_600_000, 914_000),
("ASIA", "Email"): (160_000, 93_000),
("ASIA", "STA"): (124_000, 72_000),
("ASIA", "Predictive Routing"): (87_000, 51_000),
("ASIA", "Agent Copilot"): (0, 0),
("ASIA", "Supervisor Copilot"): (0, 0),
("EMEA", "WFM"): (824_000, 687_000),
("EMEA", "Email"): (282_000, 235_000),
("EMEA", "STA"): (157_000, 131_000),
("EMEA", "Agent Copilot"): (77_000, 64_000),
("EMEA", "Supervisor Copilot"): (59_000, 49_000),
("EMEA", "Predictive Routing"): (7_000, 6_000),
}
#: The deck's own (rounded) summary rows — slides 8-9.
SLIDE_TOTALS: dict = {
"regional_3yr": {"NA": 6_900_000, "ANZ": 5_900_000,
"ASIA": 1_100_000, "EMEA": 1_200_000},
"capability_3yr": {"Agent Copilot": 7_400_000, "WFM": 3_000_000,
"Email": 2_900_000, "STA": 814_000,
"Predictive Routing": 526_000,
"Supervisor Copilot": 367_000},
"total_3yr": 15_000_000,
"total_annual": 13_600_000,
}
#: Verbatim TCO anchors — slides 5-6.
TCO_VERBATIM: dict[str, float] = {
"current_annual": 7_300_000, # current global spend / yr
"current_3yr": 22_000_000,
"ccaas_annual": 4_300_000, # licence run-rate / yr
"ccaas_3yr": 15_400_000, # deck's 3-yr CCaaS investment (no ramp, no AI costs)
"prof_services_y1": 2_400_000,
"training_y1": 167_000,
"npv_discount_rate": 0.135, # deck's benefit-NPV rate
}
#: Genesys/Broadreach deployment schedule (slides 17-21), months from
#: Jan 2026 inclusive. Benefits realize IMPL + 3 months.
IMPL_MONTH = {"NA": 18, "ANZ": 21, "EMEA": 24, "ASIA": 27}
BENEFIT_LAG_MONTHS = 3
REALIZE_MONTH = {r: m + BENEFIT_LAG_MONTHS for r, m in IMPL_MONTH.items()}
#: NA Gantt exception: Email implemented Jan 2027, realizes Apr 2027.
NA_EMAIL_IMPL_MONTH = 13
DEFAULT_RAMP_MONTHS = 12 # Genesys ramp programme
DEFAULT_TERMINATION = dt.date(2027, 12, 31) # current-platform term contracts
# ── Region ⇄ site mapping ────────────────────────────────────────────
def site_region(site_name: str) -> str:
"""Map a tokencalc site to its Appendix-4 region (APAC * → ASIA)."""
return {"NAM": "NA", "AUZ": "ANZ", "EMEA": "EMEA"}.get(site_name, "ASIA")
def region_site_names(sites: list[SiteInput]) -> dict[str, list[str]]:
return {r: [s.site_name for s in sites if site_region(s.site_name) == r]
for r in REGIONS}
def region_agents(sites: list[SiteInput]) -> dict[str, int]:
return {r: sum(s.agents for s in sites if site_region(s.site_name) == r)
for r in REGIONS}
# ── Verbatim benefit helpers ─────────────────────────────────────────
def verbatim_dataframe() -> pd.DataFrame:
"""Long DataFrame of the verbatim benefits: region, capability, annual, three_yr."""
return pd.DataFrame(
[{"region": r, "capability": c, "annual": a, "three_yr": t}
for (r, c), (a, t) in VERBATIM_BENEFITS.items()]
)
def crossfoot_tolerance(value: float) -> float:
"""The deck rounds to $0.1M and its own tables cross-foot ±$50-120K."""
return max(100_000, 0.015 * value)
# ── Schedules & rollouts ─────────────────────────────────────────────
def build_rollouts(
sites: list[SiteInput],
na_email_early: bool = True,
ramp_months: int = DEFAULT_RAMP_MONTHS,
) -> tuple[RolloutPlan, RolloutPlan, RolloutPlan]:
"""(token, email_token, benefit) rollout plans on the deck's schedule.
``RolloutPlan.go_live_month = m`` means active from month m+1; the
deck's labels are inclusive (NA "realizes Sep 2027" ⇒ September
counts), so keys are set to label 1. The benefit plan is keyed by
region (plus ``NA_EMAIL`` for the NA Gantt exception); the token
plans are keyed by site.
"""
token = RolloutPlan(
contract_start="2026-01", build_months=max(IMPL_MONTH.values()),
ramp_months=ramp_months,
first_year_platform_discount=0.0, # licences are handled verbatim, not by this plan
go_live_month={s.site_name: IMPL_MONTH[site_region(s.site_name)] - 1
for s in sites},
)
email = dataclasses.replace(
token,
go_live_month={**token.go_live_month,
"NAM": (NA_EMAIL_IMPL_MONTH - 1) if na_email_early
else IMPL_MONTH["NA"] - 1},
)
benefit = RolloutPlan(
first_year_platform_discount=0.0,
go_live_month={**{r: REALIZE_MONTH[r] - 1 for r in REGIONS},
"NA_EMAIL": (NA_EMAIL_IMPL_MONTH + BENEFIT_LAG_MONTHS - 1)
if na_email_early else REALIZE_MONTH["NA"] - 1},
)
return token, email, benefit
def benefits_by_year(
benefit_rollout: RolloutPlan, na_email_early: bool = True
) -> pd.DataFrame:
"""Phase each verbatim 3-yr value by its region's realization window.
Scaling is at the finest grain (region × capability), so every
verbatim per-region, per-capability, and grand total is reproduced
exactly. Long DataFrame: region, capability, year, benefit.
"""
rows = []
for (region, cap), (_annual, three_yr) in VERBATIM_BENEFITS.items():
key = ("NA_EMAIL" if (region == "NA" and cap == "Email" and na_email_early)
else region)
live = [benefit_rollout.live_months_in_year(key, YEAR_INDEX[y]) for y in YEARS]
total_live = sum(live)
for y, m in zip(YEARS, live):
rows.append({"region": region, "capability": cap, "year": y,
"benefit": three_yr * m / total_live if total_live else 0.0})
return pd.DataFrame(rows)
# ── Base cost lines (verbatim + contract mechanics) ──────────────────
def current_months_in_year(termination: dt.date, cal_year: int) -> int:
"""Months a term contract bills in ``cal_year`` (through its termination month)."""
if cal_year < termination.year:
return 12
if cal_year > termination.year:
return 0
return termination.month
def current_state_inputs(
sites: list[SiteInput],
total_annual: float | None = None,
termination: dt.date = DEFAULT_TERMINATION,
) -> pd.DataFrame:
"""Per-region current-platform inputs, seeded by agent share of the
verbatim global spend. Region-indexed; annual_cost and
contract_termination are the editable columns."""
total = TCO_VERBATIM["current_annual"] if total_annual is None else total_annual
agents = region_agents(sites)
total_agents = sum(agents.values())
return pd.DataFrame([
{"region": r,
"agents": agents[r],
"share": agents[r] / total_agents,
"annual_cost": total * agents[r] / total_agents,
"contract_termination": termination,
"confidence": "🟡 agent-share allocation of the verbatim total"}
for r in REGIONS
]).set_index("region")
def current_costs_by_year(current_state: pd.DataFrame) -> dict[int, float]:
"""Existing-platform run-off per calendar year (the double-billing line)."""
return {
y: float(sum(
row["annual_cost"]
* current_months_in_year(row["contract_termination"], y) / 12
for _, row in current_state.iterrows()))
for y in YEARS
}
def licence_months_in_year(year_index: int, ramp_months: int) -> int:
"""Ramp programme: licence billing starts in calendar month ramp_months + 1."""
start, end = 12 * (year_index - 1) + 1, 12 * year_index
return max(0, end - max(start, ramp_months + 1) + 1)
def licence_costs_by_year(
ramp_months: int = DEFAULT_RAMP_MONTHS, annual: float | None = None
) -> dict[int, float]:
rate = TCO_VERBATIM["ccaas_annual"] if annual is None else annual
return {y: rate * licence_months_in_year(YEAR_INDEX[y], ramp_months) / 12
for y in YEARS}
def ps_costs_by_year() -> dict[int, float]:
"""Base professional services + training — verbatim, year 1 only."""
return {2026: TCO_VERBATIM["prof_services_y1"] + TCO_VERBATIM["training_y1"],
2027: 0.0, 2028: 0.0}
# ── Token consumption (missing cost #1) ──────────────────────────────
def claim_scenario(email_auto_respond_rate: float = 0.255) -> Scenario:
"""Claim-level scenario: deck parameters, no consumption maturity ramp."""
return Scenario(
name="genesys-claim",
voice_bot_deflection=0.0, voice_bot_avg_minutes=0.0,
agentic_va_deflection=0.0,
voice_summarization_eligibility=0.0,
voice_knowledge_eligibility=0.0, # unused by the Appendix-4 scope set
email_auto_respond_rate=email_auto_respond_rate,
email_auto_suggest_acceptance=0.0, # Auto-Suggest is inside Copilot (V2 #1)
consumption_cost_realization={1: 1.0, 2: 1.0, 3: 1.0},
)
def autorespond_meter(tokens_per_msg: float = 0.05) -> TokenMeter:
"""Email Auto-Respond working meter — rate unpublished (🔴→🟡).
Anchor: ≈1 AI action per generated response; Genesys Cloud Copilot
meters 20 AI actions per token.
"""
return dataclasses.replace(
DEFAULT_METERS["Email AI (Auto-Respond)"],
units_per_token=1.0 / tokens_per_msg,
tokens_per_unit=tokens_per_msg,
confidence=Confidence.ESTIMATED,
notes="WORKING ASSUMPTION — rate unpublished; ≈1 AI action per generated "
"response (Genesys Cloud Copilot meters 20 AI actions/token).",
)
def build_scopes(
sites: list[SiteInput],
copilot_includes_asia: bool = False,
pr_eligibility: float = 1.0,
ai_translate_eligibility: float = 0.01,
) -> tuple[list[FeatureScope], list[FeatureScope]]:
"""(core, email) feature scopes mirroring the six deck capabilities.
No ``adoption_curve`` on any scope — a curve would silently override
the claim scenario's flat consumption realization. Email scopes are
separate because NA Email implements early (own rollout plan).
"""
all_names = [s.site_name for s in sites]
asia = [n for n in all_names if site_region(n) == "ASIA"]
non_asia = [n for n in all_names if site_region(n) != "ASIA"]
copilot_sites = non_asia + (asia if copilot_includes_asia else [])
core = [
FeatureScope("Agent Copilot [named]", copilot_sites, phase=1),
FeatureScope("Speech & Text Analytics [named]", all_names, phase=1),
FeatureScope("Predictive Routing", all_names, phase=1,
eligibility_pct=pr_eligibility),
# $0 by Rule 1 (Copilot covers summarization) — kept visible.
FeatureScope("AI Summary & Insights", copilot_sites, phase=1),
# Supervisor Copilot small-volume proxy.
FeatureScope("AI Translate", asia + ["EMEA"], phase=1,
eligibility_pct=ai_translate_eligibility),
]
email = [FeatureScope("Email AI (Auto-Respond)", all_names, phase=1)]
return core, email
def token_costs_by_year(
sites: list[SiteInput],
meters: dict[str, TokenMeter],
pricing: dict[str, TokenPricing],
scenario: Scenario,
core_scopes: list[FeatureScope],
email_scopes: list[FeatureScope],
token_rollout: RolloutPlan,
email_rollout: RolloutPlan,
use_contracted: bool = False,
) -> pd.DataFrame:
"""Engine-computed token costs, rollout-gated, per calendar year.
Long DataFrame: cost_line, scope, annual_cost, confidence, year.
"""
frames = []
for y in YEARS:
for scopes, rollout in ((core_scopes, token_rollout),
(email_scopes, email_rollout)):
part = calculate_total_cost(
sites, scopes, meters, pricing, scenario, YEAR_INDEX[y],
include_platform=False, use_contracted=use_contracted,
rollout=rollout,
)
part["year"] = y
frames.append(part)
return pd.concat(frames, ignore_index=True)
# ── AI implementation effort (missing cost #2, V2 LoE) ───────────────
#: (low, high) Y1 hours — docs/ctm_ai_labour_estimate_V2.md.
AI_IMPL_HOURS: dict[str, tuple[float, float]] = {
"Agent Copilot": (1_200, 1_800), # voice + digital incl. email Auto-Suggest
"Email Auto-Respond": (800, 1_400), # separate flow; needs SoR integration
"STA": (800, 1_200), # topics, programs, tuning × 7 languages
"Supervisor Copilot": (200, 400),
"Predictive Routing": (400, 700),
"Cross-cutting": (1_000, 1_800), # governance, PM, test env, integration
}
KB_READINESS_HOURS = (500, 1_500) # prerequisite project — flagged separately
STEADY_STATE_HOURS = (500, 900) # absolute h/yr, 2027-2028
DEFAULT_BLENDED_RATE = 225.0
SMELL_TEST_FLOOR = 0.15 # impl ≥ 15% of benefit claim, or flag
def impl_feature_regions(copilot_includes_asia: bool = False) -> dict[str, list[str]]:
"""Which regions each implementation workstream serves."""
return {
"Agent Copilot": (["NA", "ANZ", "EMEA"]
+ (["ASIA"] if copilot_includes_asia else [])),
"Email Auto-Respond": list(REGIONS),
"STA": list(REGIONS),
"Supervisor Copilot": ["NA", "ANZ", "EMEA"], # deck: $0 SupCopilot in ASIA
"Predictive Routing": list(REGIONS),
"Cross-cutting": list(REGIONS),
}
def hours_pick(rng: tuple[float, float], mode: str) -> float:
low, high = rng
return {"low": low, "mid": (low + high) / 2, "high": high}[mode]
def impl_year_fractions(impl_month: int) -> list[float]:
"""Spend spreads uniformly from contract start (month 0) to the impl month."""
prev, fracs = 0, []
for yi in (1, 2, 3):
cur = min(12 * yi, impl_month)
fracs.append((cur - prev) / impl_month)
prev = cur
return fracs
def region_impl_month(feature: str, region: str, na_email_early: bool = True) -> int:
if feature == "Email Auto-Respond" and region == "NA" and na_email_early:
return NA_EMAIL_IMPL_MONTH
return IMPL_MONTH[region]
def build_impl_costs(
sites: list[SiteInput],
mode: str = "mid",
rate: float = DEFAULT_BLENDED_RATE,
include_kb: bool = True,
copilot_includes_asia: bool = False,
na_email_early: bool = True,
) -> tuple[pd.DataFrame, dict[int, float], dict[int, float], dict[int, float]]:
"""V2 hours-range × rate model (swap point for the future LoE engine).
Returns (detail_df, impl_by_year, kb_by_year, steady_by_year).
Hours allocate to each workstream's scoped regions by agent share;
steady-state is booked program-level in 2027-2028.
"""
agents = region_agents(sites)
feature_regions = impl_feature_regions(copilot_includes_asia)
workstreams = dict(AI_IMPL_HOURS)
if include_kb:
workstreams["KB readiness (prerequisite)"] = KB_READINESS_HOURS
rows = []
for feature, rng in workstreams.items():
regions = feature_regions.get(feature, list(REGIONS))
scope_agents = sum(agents[r] for r in regions)
for r in regions:
hours = hours_pick(rng, mode) * agents[r] / scope_agents
fracs = impl_year_fractions(
region_impl_month(feature, r, na_email_early))
rows.append({"workstream": feature, "region": r, "hours": hours,
"cost": hours * rate,
**{y: hours * rate * f for y, f in zip(YEARS, fracs)}})
df = pd.DataFrame(rows)
is_kb = df["workstream"].str.startswith("KB")
impl_y = {y: float(df.loc[~is_kb, y].sum()) for y in YEARS}
kb_y = {y: float(df.loc[is_kb, y].sum()) for y in YEARS}
steady = hours_pick(STEADY_STATE_HOURS, mode) * rate
steady_y = {2026: 0.0, 2027: steady, 2028: steady}
return df, impl_y, kb_y, steady_y
# ── Business case (baseline-relative frame) ──────────────────────────
def case_flows(
total_cost_by_year: dict[int, float],
benefit_total_by_year: dict[int, float],
baseline_annual: float | None = None,
) -> tuple[dict[int, float], dict[int, float]]:
"""(incremental cost, net) vs the do-nothing baseline.
One frame captures both the 2026-27 double-billing penalty and the
post-termination cost-avoidance credit.
"""
base = TCO_VERBATIM["current_annual"] if baseline_annual is None else baseline_annual
inc = {y: total_cost_by_year[y] - base for y in YEARS}
net = {y: benefit_total_by_year[y] - inc[y] for y in YEARS}
return inc, net
def payback_label(net_by_year: dict[int, float]) -> str:
pb = payback_years([net_by_year[y] for y in YEARS])
if pb is None:
return f"beyond {YEARS[-1]}"
if pb == 0:
return "immediate"
m = math.ceil(pb * 12)
return f"{m} months (~{month_label(m)})"
def case_kpis(
inc: dict[int, float],
net: dict[int, float],
discount_rate: float | None = None,
) -> dict:
"""KPIs for one cost frame. Benefits are recoverable as net + inc."""
rate = TCO_VERBATIM["npv_discount_rate"] if discount_rate is None else discount_rate
net_list = [net[y] for y in YEARS]
inc_total = sum(inc.values())
net_total = sum(net_list)
return {
"benefits_3yr": net_total + inc_total,
"incremental_cost_3yr": inc_total,
"net_3yr": net_total,
"roi": (net_total / inc_total) if inc_total > 0 else None,
"npv": npv(net_list, rate),
"discount_rate": rate,
"payback": payback_label(net),
}
# ── Display helpers ──────────────────────────────────────────────────
def money(v: float) -> str:
sign, a = ("-" if v < 0 else ""), abs(v)
return f"{sign}${a/1e6:,.1f}M" if a >= 1e6 else f"{sign}${a/1e3:,.0f}K"
def html_money(v: float) -> str:
"""Plotly text with two or more bare ``$`` triggers MathJax math mode —
annotations holding several amounts must use the HTML entity instead."""
return money(v).replace("$", "&#36;")