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 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()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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("$", "$")
|
||||||
Reference in New Issue
Block a user