feat: update ctm-token-calculator to use Mercury and notebook deliverables
Replace Streamlit and JupyterLab commands with Mercury for serving interactive notebooks as web apps. Update README to reflect new architecture where notebooks are the primary deliverables, utilizing Mercury input widgets for live client tuning. Add export_report.py script to generate LLM-readable HTML/Markdown reports from the notebooks. Update corrected business case notebook to include Mercury dependency and usage instructions.
This commit is contained in:
@@ -25,13 +25,17 @@ outputs with sensitivity-aware **Floor / Realistic / Stretch** analysis.
|
|||||||
```bash
|
```bash
|
||||||
cd ctm-token-calculator
|
cd ctm-token-calculator
|
||||||
python -m venv .venv && source .venv/bin/activate
|
python -m venv .venv && source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -e ".[app,notebook,dev]"
|
||||||
|
|
||||||
# Streamlit app (7 pages: Inputs → Export)
|
# Serve the notebooks as interactive web apps (Mercury)
|
||||||
streamlit run app/streamlit_app.py
|
mercury --working-dir notebooks/
|
||||||
|
|
||||||
# JupyterLab notebook variant (same numbers, same library)
|
# Or work on them directly in JupyterLab
|
||||||
jupyter lab notebooks/ctm_token_calculator.ipynb
|
jupyter lab notebooks/
|
||||||
|
|
||||||
|
# Export the corrected business case as LLM-readable report sources
|
||||||
|
# (exports/*.html for review, exports/*.md for feeding an LLM)
|
||||||
|
python scripts/export_report.py
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
pytest
|
pytest
|
||||||
@@ -39,10 +43,21 @@ pytest
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
All math lives in the pure-Python `tokencalc/` library; the notebook and
|
**The notebooks are the deliverables.** All math lives in the pure-Python
|
||||||
Streamlit app are thin presentation layers calling the same functions —
|
`tokencalc/` library; the notebooks are thin presentation layers over it.
|
||||||
Run-All in the notebook produces identical headline numbers to the app on
|
[Mercury](https://runmercury.com) serves them as interactive web apps — the
|
||||||
default inputs.
|
`mercury` input widgets in `ctm_business_case_corrected.ipynb` let you tune
|
||||||
|
contract values, termination dates, token assumptions, and implementation
|
||||||
|
pricing live for a client, and headless runs (nbconvert, the section-10 regression
|
||||||
|
gate) simply use the widget defaults. `scripts/export_report.py` executes the
|
||||||
|
notebook and writes HTML + markdown to `exports/`; the notebook's section-12
|
||||||
|
machine-readable appendix carries every number behind the figures so an LLM
|
||||||
|
can draft the client report from the export.
|
||||||
|
|
||||||
|
| Notebook | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `notebooks/ctm_business_case_corrected.ipynb` | Client-facing corrected business case (Mercury-interactive) |
|
||||||
|
| `notebooks/ctm_token_calculator.ipynb` | Full token-cost / scenario workbench |
|
||||||
|
|
||||||
| Module | Purpose |
|
| Module | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -1,891 +0,0 @@
|
|||||||
"""
|
|
||||||
NTT DATA — CTM Token Calculator (Streamlit).
|
|
||||||
|
|
||||||
Run from the ctm-token-calculator root::
|
|
||||||
|
|
||||||
streamlit run app/streamlit_app.py
|
|
||||||
|
|
||||||
Thin presentation layer over ``tokencalc`` — all math lives in the
|
|
||||||
library, shared with the JupyterLab notebook.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Import tokencalc from the project root without install
|
|
||||||
_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
if str(_ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_ROOT))
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
import plotly.express as px
|
|
||||||
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,
|
|
||||||
CTM_DEFAULT_SITES,
|
|
||||||
CTM_DEFAULT_TAKEOUTS,
|
|
||||||
DEFAULT_METERS,
|
|
||||||
DEFAULT_PRICING,
|
|
||||||
Confidence,
|
|
||||||
CostTakeout,
|
|
||||||
FeatureScope,
|
|
||||||
SiteInput,
|
|
||||||
build_business_case,
|
|
||||||
calculate_total_benefit,
|
|
||||||
calculate_total_cost,
|
|
||||||
export_excel,
|
|
||||||
get_scenario,
|
|
||||||
meters_dataframe,
|
|
||||||
scenario_state_from_json,
|
|
||||||
scenario_state_to_json,
|
|
||||||
sites_dataframe,
|
|
||||||
)
|
|
||||||
|
|
||||||
st.set_page_config(page_title="NTT DATA — CTM Token Calculator",
|
|
||||||
page_icon="🧮", layout="wide")
|
|
||||||
|
|
||||||
YEARS = (1, 2, 3)
|
|
||||||
FEATURES = list(DEFAULT_METERS)
|
|
||||||
_DEFAULT_REALISTIC = {
|
|
||||||
k: v["realistic"] for k, v in tc_scenarios.BENEFIT_PARAMS.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── State ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _init_state(force: bool = False) -> None:
|
|
||||||
if force or "sites" not in st.session_state:
|
|
||||||
st.session_state.sites = list(CTM_DEFAULT_SITES)
|
|
||||||
st.session_state.takeouts = list(CTM_DEFAULT_TAKEOUTS)
|
|
||||||
st.session_state.scopes = [
|
|
||||||
dataclasses.replace(s) for s in CTM_DEFAULT_FEATURE_SCOPES
|
|
||||||
]
|
|
||||||
st.session_state.meters = dict(DEFAULT_METERS)
|
|
||||||
st.session_state.pricing = dict(DEFAULT_PRICING)
|
|
||||||
st.session_state.use_contracted = False
|
|
||||||
st.session_state.implementation_cost = 0.0
|
|
||||||
for k, v in _DEFAULT_REALISTIC.items(): # reset benefit sliders
|
|
||||||
tc_scenarios.BENEFIT_PARAMS[k]["realistic"] = v
|
|
||||||
|
|
||||||
|
|
||||||
_init_state()
|
|
||||||
|
|
||||||
|
|
||||||
def _state_key() -> str:
|
|
||||||
"""Stable serialization of inputs for st.cache_data keys."""
|
|
||||||
return scenario_state_to_json(
|
|
||||||
st.session_state.sites, st.session_state.takeouts, st.session_state.scopes
|
|
||||||
) + json.dumps(
|
|
||||||
{
|
|
||||||
"params": {k: v["realistic"] for k, v in tc_scenarios.BENEFIT_PARAMS.items()},
|
|
||||||
"contracted": st.session_state.use_contracted,
|
|
||||||
"impl": st.session_state.implementation_cost,
|
|
||||||
"meters": {f: m.tokens_per_unit for f, m in st.session_state.meters.items()},
|
|
||||||
"pricing": {
|
|
||||||
r: (p.list_rate_per_token, p.contracted_rate_per_token)
|
|
||||||
for r, p in st.session_state.pricing.items()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@st.cache_data(show_spinner=False)
|
|
||||||
def _cached_case(state_key: str, scenario: str) -> dict:
|
|
||||||
return build_business_case(
|
|
||||||
st.session_state.sites, st.session_state.scopes,
|
|
||||||
st.session_state.meters, st.session_state.pricing,
|
|
||||||
st.session_state.takeouts, scenario,
|
|
||||||
implementation_cost=st.session_state.implementation_cost,
|
|
||||||
use_contracted=st.session_state.use_contracted,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _case(scenario: str) -> dict:
|
|
||||||
return _cached_case(_state_key(), scenario)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Sidebar ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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",
|
|
||||||
])
|
|
||||||
st.sidebar.divider()
|
|
||||||
scenario_name = st.sidebar.radio(
|
|
||||||
"Scenario", ["floor", "realistic", "stretch"], index=1, horizontal=True
|
|
||||||
)
|
|
||||||
year = st.sidebar.radio("Year", YEARS, horizontal=True)
|
|
||||||
if st.sidebar.button("Reset to CTM defaults"):
|
|
||||||
_init_state(force=True)
|
|
||||||
st.cache_data.clear()
|
|
||||||
st.rerun()
|
|
||||||
st.sidebar.caption(
|
|
||||||
"⚠️ Planning tool — published list rates unless overridden; "
|
|
||||||
"not contractual pricing."
|
|
||||||
)
|
|
||||||
|
|
||||||
sites: list[SiteInput] = st.session_state.sites
|
|
||||||
scopes: list[FeatureScope] = st.session_state.scopes
|
|
||||||
meters = st.session_state.meters
|
|
||||||
pricing = st.session_state.pricing
|
|
||||||
scenario = get_scenario(scenario_name)
|
|
||||||
|
|
||||||
|
|
||||||
def _users_warning() -> None:
|
|
||||||
total = sum(s.named_users for s in sites)
|
|
||||||
if total != CONTRACTED_NAMED_USERS:
|
|
||||||
st.warning(
|
|
||||||
f"Named users across sites = {total:,} ≠ contracted licence "
|
|
||||||
f"count {CONTRACTED_NAMED_USERS:,}."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 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 ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
elif page == "1. Inputs":
|
|
||||||
st.header("Inputs")
|
|
||||||
st.caption("Site data outside NAM is **estimated — confirm with CTM data**.")
|
|
||||||
_users_warning()
|
|
||||||
|
|
||||||
df = sites_dataframe(sites)
|
|
||||||
edited = st.data_editor(df, num_rows="dynamic", key="sites_editor")
|
|
||||||
if st.button("Apply site changes"):
|
|
||||||
try:
|
|
||||||
st.session_state.sites = [
|
|
||||||
SiteInput(
|
|
||||||
**{
|
|
||||||
**row,
|
|
||||||
"languages": [
|
|
||||||
x.strip() for x in str(row["languages"]).split(",") if x.strip()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
for row in edited.to_dict("records")
|
|
||||||
]
|
|
||||||
st.cache_data.clear()
|
|
||||||
st.success("Sites updated.")
|
|
||||||
st.rerun()
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
st.error(f"Validation failed: {e}")
|
|
||||||
|
|
||||||
st.subheader("Cost takeouts")
|
|
||||||
tdf = pd.DataFrame(
|
|
||||||
[
|
|
||||||
{"name": t.name, "annual_cost": t.annual_cost,
|
|
||||||
"start_year": t.start_year, "confidence": t.confidence.value,
|
|
||||||
"notes": t.notes}
|
|
||||||
for t in st.session_state.takeouts
|
|
||||||
]
|
|
||||||
)
|
|
||||||
tedit = st.data_editor(
|
|
||||||
tdf, num_rows="dynamic", key="takeouts_editor",
|
|
||||||
column_config={
|
|
||||||
"confidence": st.column_config.SelectboxColumn(
|
|
||||||
options=[c.value for c in Confidence]
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if st.button("Apply takeout changes"):
|
|
||||||
try:
|
|
||||||
st.session_state.takeouts = [
|
|
||||||
CostTakeout(
|
|
||||||
name=r["name"], annual_cost=float(r["annual_cost"] or 0),
|
|
||||||
start_year=int(r["start_year"] or 1),
|
|
||||||
confidence=Confidence(r["confidence"]), notes=r["notes"] or "",
|
|
||||||
)
|
|
||||||
for r in tedit.to_dict("records")
|
|
||||||
]
|
|
||||||
st.cache_data.clear()
|
|
||||||
st.success("Takeouts updated.")
|
|
||||||
st.rerun()
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
st.error(f"Validation failed: {e}")
|
|
||||||
|
|
||||||
st.subheader("Save / load scenario")
|
|
||||||
col1, col2 = st.columns(2)
|
|
||||||
with col1:
|
|
||||||
st.download_button(
|
|
||||||
"Download scenario JSON",
|
|
||||||
scenario_state_to_json(sites, st.session_state.takeouts, scopes),
|
|
||||||
file_name="ctm_scenario.json", mime="application/json",
|
|
||||||
)
|
|
||||||
with col2:
|
|
||||||
up = st.file_uploader("Load scenario JSON", type="json")
|
|
||||||
if up is not None and st.button("Load"):
|
|
||||||
# 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()
|
|
||||||
st.success("Scenario loaded.")
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
# ── Page 2: Token Meters ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
elif page == "2. Token Meters":
|
|
||||||
st.header("Token Meters")
|
|
||||||
st.dataframe(meters_dataframe(meters), width="stretch", hide_index=True)
|
|
||||||
|
|
||||||
st.subheader("Override a meter rate")
|
|
||||||
feature = st.selectbox("Feature", FEATURES)
|
|
||||||
m = meters[feature]
|
|
||||||
override = st.toggle("Override default", key=f"ovr_{feature}")
|
|
||||||
if override:
|
|
||||||
new_rate = st.number_input(
|
|
||||||
"tokens per unit (per user/month for per-user meters)",
|
|
||||||
value=float(m.tokens_per_unit), min_value=0.0, step=0.005,
|
|
||||||
format="%.4f",
|
|
||||||
)
|
|
||||||
if st.button("Apply override"):
|
|
||||||
meters[feature] = dataclasses.replace(
|
|
||||||
m,
|
|
||||||
tokens_per_unit=new_rate,
|
|
||||||
units_per_token=(1 / new_rate if new_rate and m.units_per_token else 0.0),
|
|
||||||
confidence=Confidence.ESTIMATED,
|
|
||||||
notes=m.notes + " [rate overridden by user]",
|
|
||||||
)
|
|
||||||
st.cache_data.clear()
|
|
||||||
st.success(f"{feature} now {new_rate} tokens/unit (flagged estimated).")
|
|
||||||
|
|
||||||
st.subheader("Token pricing per region")
|
|
||||||
st.session_state.use_contracted = st.toggle(
|
|
||||||
"Apply contracted rate (if known) instead of list rate",
|
|
||||||
value=st.session_state.use_contracted,
|
|
||||||
)
|
|
||||||
for region, p in pricing.items():
|
|
||||||
c1, c2 = st.columns(2)
|
|
||||||
with c1:
|
|
||||||
lr = st.number_input(
|
|
||||||
f"{region} — list $/token", value=float(p.list_rate_per_token),
|
|
||||||
min_value=0.0, key=f"list_{region}",
|
|
||||||
)
|
|
||||||
with c2:
|
|
||||||
cr = st.number_input(
|
|
||||||
f"{region} — contracted $/token (0 = unknown)",
|
|
||||||
value=float(p.contracted_rate_per_token or 0.0),
|
|
||||||
min_value=0.0, key=f"con_{region}",
|
|
||||||
)
|
|
||||||
pricing[region] = dataclasses.replace(
|
|
||||||
p, list_rate_per_token=lr,
|
|
||||||
contracted_rate_per_token=cr or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Page 3: Cost Model ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
elif page == "3. Cost Model":
|
|
||||||
st.header("Cost Model")
|
|
||||||
_users_warning()
|
|
||||||
|
|
||||||
st.subheader("Feature enablement & phasing")
|
|
||||||
st.caption("Phase = model year the feature switches on at that site; 0 = off.")
|
|
||||||
site_names = [s.site_name for s in sites]
|
|
||||||
matrix = pd.DataFrame(0, index=site_names, columns=FEATURES, dtype=int)
|
|
||||||
for sc in scopes:
|
|
||||||
for sn in sc.enabled_sites:
|
|
||||||
if sn in matrix.index:
|
|
||||||
matrix.loc[sn, sc.feature] = sc.phase
|
|
||||||
edited_matrix = st.data_editor(matrix, key="phasing_matrix")
|
|
||||||
if st.button("Apply phasing"):
|
|
||||||
new_scopes: list[FeatureScope] = []
|
|
||||||
for feature in FEATURES:
|
|
||||||
for phase in (1, 2, 3):
|
|
||||||
enabled = [sn for sn in site_names
|
|
||||||
if int(edited_matrix.loc[sn, feature]) == phase]
|
|
||||||
if enabled:
|
|
||||||
template = next(
|
|
||||||
(s for s in scopes if s.feature == feature), None
|
|
||||||
)
|
|
||||||
new_scopes.append(
|
|
||||||
FeatureScope(
|
|
||||||
feature, enabled, phase=phase,
|
|
||||||
adoption_curve=(
|
|
||||||
template.adoption_curve if template else {}
|
|
||||||
),
|
|
||||||
deflection_target=(
|
|
||||||
template.deflection_target if template else None
|
|
||||||
),
|
|
||||||
eligibility_pct=(
|
|
||||||
template.eligibility_pct if template else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
st.session_state.scopes = new_scopes
|
|
||||||
st.cache_data.clear()
|
|
||||||
st.success("Phasing updated.")
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
frames = []
|
|
||||||
for y in YEARS:
|
|
||||||
d = calculate_total_cost(
|
|
||||||
sites, scopes, meters, pricing, scenario, y,
|
|
||||||
use_contracted=st.session_state.use_contracted,
|
|
||||||
)
|
|
||||||
d["year"] = f"Y{y}"
|
|
||||||
frames.append(d)
|
|
||||||
cost_3y = pd.concat(frames, ignore_index=True)
|
|
||||||
|
|
||||||
this_year = frames[year - 1]
|
|
||||||
total = this_year["annual_cost"].sum()
|
|
||||||
unknown = this_year[this_year["confidence"] == "unknown"]["annual_cost"].sum()
|
|
||||||
c1, c2 = st.columns(2)
|
|
||||||
c1.metric(f"Year {year} total cost ({scenario_name})", f"${total:,.0f}")
|
|
||||||
c2.metric("of which 🔴 unknown-rate features", f"${unknown:,.0f}",
|
|
||||||
help="Range driven by unsourced meter rates — total could move "
|
|
||||||
"materially once these are confirmed.")
|
|
||||||
|
|
||||||
st.plotly_chart(
|
|
||||||
px.bar(cost_3y, x="year", y="annual_cost", color="cost_line",
|
|
||||||
title=f"Cost breakdown by feature — {scenario_name}",
|
|
||||||
labels={"annual_cost": "$/yr"}),
|
|
||||||
width="stretch", key="cost_stack",
|
|
||||||
)
|
|
||||||
icon_map = {c.value: c.icon for c in Confidence}
|
|
||||||
show = this_year.copy()
|
|
||||||
show["confidence"] = show["confidence"].map(
|
|
||||||
lambda v: f"{icon_map.get(v, '')} {v}"
|
|
||||||
)
|
|
||||||
st.dataframe(show.sort_values("annual_cost", ascending=False),
|
|
||||||
width="stretch", hide_index=True)
|
|
||||||
|
|
||||||
# ── Page 4: Benefit Model ────────────────────────────────────────────
|
|
||||||
|
|
||||||
elif page == "4. Benefit Model":
|
|
||||||
st.header("Benefit Model")
|
|
||||||
st.caption("Sliders adjust the pressure-tested (realistic) parameters; "
|
|
||||||
"the Genesys-claim figures stay fixed for comparison.")
|
|
||||||
|
|
||||||
cols = st.columns(3)
|
|
||||||
for i, (key, vals) in enumerate(tc_scenarios.BENEFIT_PARAMS.items()):
|
|
||||||
with cols[i % 3]:
|
|
||||||
tc_scenarios.BENEFIT_PARAMS[key]["realistic"] = st.slider(
|
|
||||||
key.replace("_", " "),
|
|
||||||
0.0, max(1.0, vals["claim"]),
|
|
||||||
value=float(vals["realistic"]), step=0.005, format="%.3f",
|
|
||||||
key=f"bp_{key}",
|
|
||||||
)
|
|
||||||
|
|
||||||
frames = []
|
|
||||||
for y in YEARS:
|
|
||||||
d = calculate_total_benefit(sites, scopes, scenario, y, params="realistic")
|
|
||||||
d["year"] = f"Y{y}"
|
|
||||||
frames.append(d)
|
|
||||||
ben_3y = pd.concat(frames, ignore_index=True)
|
|
||||||
|
|
||||||
st.metric(f"Year {year} total benefit ({scenario_name})",
|
|
||||||
f"${frames[year - 1]['annual_value'].sum():,.0f}")
|
|
||||||
st.plotly_chart(
|
|
||||||
px.bar(ben_3y, x="year", y="annual_value", color="benefit_line",
|
|
||||||
title=f"Benefit breakdown by source — {scenario_name}",
|
|
||||||
labels={"annual_value": "$/yr"}),
|
|
||||||
width="stretch", key="benefit_stack",
|
|
||||||
)
|
|
||||||
|
|
||||||
claim = calculate_total_benefit(sites, scopes, scenario, year, params="claim")
|
|
||||||
realistic = frames[year - 1]
|
|
||||||
comp = pd.merge(
|
|
||||||
claim[["benefit_line", "annual_value"]].rename(
|
|
||||||
columns={"annual_value": "Genesys claim"}),
|
|
||||||
realistic[["benefit_line", "annual_value"]].rename(
|
|
||||||
columns={"annual_value": "Pressure-tested"}),
|
|
||||||
on="benefit_line", how="outer",
|
|
||||||
).fillna(0)
|
|
||||||
fig = go.Figure([
|
|
||||||
go.Bar(name="Genesys claim", x=comp.benefit_line, y=comp["Genesys claim"]),
|
|
||||||
go.Bar(name="Pressure-tested realistic", x=comp.benefit_line,
|
|
||||||
y=comp["Pressure-tested"]),
|
|
||||||
])
|
|
||||||
fig.update_layout(barmode="group", yaxis_tickformat="$,.0f",
|
|
||||||
title=f"Genesys claim vs pressure-tested — Year {year}")
|
|
||||||
st.plotly_chart(fig, width="stretch", key="claim_vs_real")
|
|
||||||
|
|
||||||
# ── Page 5: Business Case ────────────────────────────────────────────
|
|
||||||
|
|
||||||
elif page == "5. Business Case":
|
|
||||||
st.header("Business Case")
|
|
||||||
st.session_state.implementation_cost = st.number_input(
|
|
||||||
"One-off implementation cost (amortized over 3 years)",
|
|
||||||
value=float(st.session_state.implementation_cost), min_value=0.0,
|
|
||||||
step=50_000.0,
|
|
||||||
)
|
|
||||||
case = _case(scenario_name)
|
|
||||||
|
|
||||||
pb = case["payback_period_years"]
|
|
||||||
c1, c2, c3 = st.columns(3)
|
|
||||||
c1.metric("NPV @ 8%", f"${case['npv']:,.0f}")
|
|
||||||
c2.metric("Payback", f"{pb:.2f} yrs" if pb is not None else "never")
|
|
||||||
c3.metric("3-Year ROI", f"{case['roi_3yr']:.0%}" if case["roi_3yr"] else "n/a")
|
|
||||||
|
|
||||||
pnl = pd.concat(
|
|
||||||
[
|
|
||||||
case["cost_by_year"].drop(columns="confidence"),
|
|
||||||
case["takeouts_by_year"].drop(columns="confidence"),
|
|
||||||
case["benefit_by_year"].drop(columns="confidence"),
|
|
||||||
case["net_by_year"],
|
|
||||||
],
|
|
||||||
ignore_index=True,
|
|
||||||
)
|
|
||||||
pnl["3-yr Total"] = pnl[["Y1", "Y2", "Y3"]].sum(axis=1)
|
|
||||||
st.dataframe(
|
|
||||||
pnl, width="stretch", hide_index=True,
|
|
||||||
column_config={
|
|
||||||
c: st.column_config.NumberColumn(c, format="$%,.0f")
|
|
||||||
for c in ("Y1", "Y2", "Y3", "3-yr Total")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fig = go.Figure()
|
|
||||||
for name in ("floor", "realistic", "stretch"):
|
|
||||||
c = _case(name)
|
|
||||||
fig.add_scatter(
|
|
||||||
x=c["cumulative_net"].year, y=c["cumulative_net"].cumulative_net,
|
|
||||||
mode="lines+markers", name=name.capitalize(),
|
|
||||||
)
|
|
||||||
fig.update_layout(title="Cumulative net cash flow by scenario",
|
|
||||||
xaxis_title="Year", yaxis_tickformat="$,.0f")
|
|
||||||
st.plotly_chart(fig, width="stretch", key="cum_net")
|
|
||||||
|
|
||||||
# ── Page 6: Sensitivity ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
elif page == "6. Sensitivity Analysis":
|
|
||||||
st.header("Sensitivity Analysis")
|
|
||||||
base_npv = _case(scenario_name)["npv"]
|
|
||||||
st.caption(f"Base 3-yr NPV ({scenario_name}): ${base_npv:,.0f}")
|
|
||||||
|
|
||||||
def _npv_with(**overrides) -> float:
|
|
||||||
sc = dataclasses.replace(scenario, **overrides)
|
|
||||||
return build_business_case(
|
|
||||||
sites, scopes, meters, pricing, st.session_state.takeouts, sc,
|
|
||||||
implementation_cost=st.session_state.implementation_cost,
|
|
||||||
use_contracted=st.session_state.use_contracted,
|
|
||||||
)["npv"]
|
|
||||||
|
|
||||||
drivers = [
|
|
||||||
"voice_bot_deflection", "voice_bot_avg_minutes", "agentic_va_deflection",
|
|
||||||
"voice_summarization_eligibility", "voice_knowledge_eligibility",
|
|
||||||
"email_auto_respond_rate", "email_auto_suggest_acceptance",
|
|
||||||
]
|
|
||||||
rows = []
|
|
||||||
for d in drivers:
|
|
||||||
base_v = getattr(scenario, d)
|
|
||||||
lo = base_v * 0.75 if d == "voice_bot_avg_minutes" else min(base_v * 0.75, 1.0)
|
|
||||||
hi = base_v * 1.25 if d == "voice_bot_avg_minutes" else min(base_v * 1.25, 1.0)
|
|
||||||
rows.append({"driver": d,
|
|
||||||
"low": _npv_with(**{d: lo}) - base_npv,
|
|
||||||
"high": _npv_with(**{d: hi}) - base_npv})
|
|
||||||
torn = pd.DataFrame(rows)
|
|
||||||
torn["swing"] = (torn.high - torn.low).abs()
|
|
||||||
torn = torn.sort_values("swing")
|
|
||||||
fig = go.Figure([
|
|
||||||
go.Bar(y=torn.driver, x=torn.low, orientation="h", name="-25%"),
|
|
||||||
go.Bar(y=torn.driver, x=torn.high, orientation="h", name="+25%"),
|
|
||||||
])
|
|
||||||
fig.update_layout(barmode="overlay", title="Tornado — NPV impact of ±25%",
|
|
||||||
xaxis_tickformat="$,.0f")
|
|
||||||
st.plotly_chart(fig, width="stretch", key="tornado")
|
|
||||||
|
|
||||||
st.subheader("Two-variable heatmap")
|
|
||||||
xs = np.linspace(0.0, 0.50, 6) # Email Auto-Respond rate
|
|
||||||
ys = np.linspace(0.0, 0.25, 6) # Agentic VA deflection
|
|
||||||
z = [[_npv_with(email_auto_respond_rate=float(x),
|
|
||||||
agentic_va_deflection=float(yv)) for x in xs] for yv in ys]
|
|
||||||
fig = go.Figure(go.Heatmap(
|
|
||||||
x=[f"{x:.0%}" for x in xs], y=[f"{yv:.0%}" for yv in ys], z=z,
|
|
||||||
colorbar={"title": "3-yr NPV"},
|
|
||||||
))
|
|
||||||
fig.update_layout(title="NPV: Email Auto-Respond rate × Agentic VA deflection",
|
|
||||||
xaxis_title="Email Auto-Respond rate",
|
|
||||||
yaxis_title="Agentic VA deflection")
|
|
||||||
st.plotly_chart(fig, width="stretch", key="heatmap")
|
|
||||||
|
|
||||||
st.subheader("Break-even finder")
|
|
||||||
rates = np.linspace(0.0, 0.50, 26)
|
|
||||||
npvs = [_npv_with(email_auto_respond_rate=float(r)) for r in rates]
|
|
||||||
breakeven = next((r for r, v in zip(rates, npvs) if v >= 0), None)
|
|
||||||
if npvs[0] >= 0:
|
|
||||||
st.success(f"Case is NPV-positive even at 0% Auto-Respond "
|
|
||||||
f"(${npvs[0]:,.0f}).")
|
|
||||||
elif breakeven is not None:
|
|
||||||
st.info(f"Break-even at ~{breakeven:.0%} email Auto-Respond rate.")
|
|
||||||
else:
|
|
||||||
st.error("No break-even within 0–50% Auto-Respond.")
|
|
||||||
st.plotly_chart(
|
|
||||||
px.line(x=rates, y=npvs,
|
|
||||||
labels={"x": "Email Auto-Respond rate", "y": "3-yr NPV ($)"}),
|
|
||||||
width="stretch", key="breakeven",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Page 7: Export ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
elif page == "7. Export":
|
|
||||||
st.header("Export")
|
|
||||||
case = _case(scenario_name)
|
|
||||||
cost_frames, ben_frames = [], []
|
|
||||||
for y in YEARS:
|
|
||||||
d = calculate_total_cost(sites, scopes, meters, pricing, scenario, y,
|
|
||||||
use_contracted=st.session_state.use_contracted)
|
|
||||||
d["year"] = f"Y{y}"
|
|
||||||
cost_frames.append(d)
|
|
||||||
b = calculate_total_benefit(sites, scopes, scenario, y)
|
|
||||||
b["year"] = f"Y{y}"
|
|
||||||
ben_frames.append(b)
|
|
||||||
|
|
||||||
comparison = pd.DataFrame([
|
|
||||||
{"scenario": n, "NPV": _case(n)["npv"],
|
|
||||||
"payback_years": _case(n)["payback_period_years"],
|
|
||||||
"roi_3yr": _case(n)["roi_3yr"]}
|
|
||||||
for n in ("floor", "realistic", "stretch")
|
|
||||||
])
|
|
||||||
|
|
||||||
pnl = pd.concat(
|
|
||||||
[case["cost_by_year"].drop(columns="confidence"),
|
|
||||||
case["takeouts_by_year"].drop(columns="confidence"),
|
|
||||||
case["benefit_by_year"].drop(columns="confidence"),
|
|
||||||
case["net_by_year"]],
|
|
||||||
ignore_index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
buf = io.BytesIO()
|
|
||||||
with pd.ExcelWriter(buf, engine="openpyxl") as writer:
|
|
||||||
sites_dataframe(sites).to_excel(writer, sheet_name="Inputs", index=False)
|
|
||||||
meters_dataframe(meters).to_excel(writer, sheet_name="Meters", index=False)
|
|
||||||
pd.concat(cost_frames).to_excel(writer, sheet_name="Cost detail", index=False)
|
|
||||||
pd.concat(ben_frames).to_excel(writer, sheet_name="Benefit detail", index=False)
|
|
||||||
pnl.to_excel(writer, sheet_name="Business case", index=False)
|
|
||||||
comparison.to_excel(writer, sheet_name="Scenario comparison", index=False)
|
|
||||||
st.download_button(
|
|
||||||
"⬇️ Download Excel workbook",
|
|
||||||
buf.getvalue(),
|
|
||||||
file_name=f"ctm_token_calculator_{scenario_name}.xlsx",
|
|
||||||
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
)
|
|
||||||
st.download_button(
|
|
||||||
"⬇️ Download scenario JSON",
|
|
||||||
scenario_state_to_json(sites, st.session_state.takeouts, scopes),
|
|
||||||
file_name="ctm_scenario.json", mime="application/json",
|
|
||||||
)
|
|
||||||
st.dataframe(comparison, width="stretch", hide_index=True)
|
|
||||||
43
studies/202512_GenesysCX/ctm-token-calculator/config.toml
Normal file
43
studies/202512_GenesysCX/ctm-token-calculator/config.toml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Mercury app-shell theme — matched to the notebooks' chart chrome
|
||||||
|
# (same warm-neutral surfaces and capability blue as the figures).
|
||||||
|
#
|
||||||
|
# Loaded from the directory where you launch `mercury` (this project root);
|
||||||
|
# restart the server to apply changes. Full key list: mercury/config.py
|
||||||
|
# DEFAULT_THEME — anything omitted is derived from the values below.
|
||||||
|
|
||||||
|
[main]
|
||||||
|
title = "CTM × Genesys — Business Case"
|
||||||
|
favicon_emoji = "📊"
|
||||||
|
footer = "CTM × Genesys CCaaS study"
|
||||||
|
notebooks_button_label = "Analyses"
|
||||||
|
|
||||||
|
[welcome]
|
||||||
|
header = "CTM × Genesys CCaaS"
|
||||||
|
message = """
|
||||||
|
Interactive business-case notebooks. **Corrected Business Case** keeps
|
||||||
|
Genesys's claimed benefits verbatim and adds the costs the pitch omitted —
|
||||||
|
tune the 🟡 inputs live for the client, then export the personalized report
|
||||||
|
source with `python scripts/export_report.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
# text — same ink scale as the figures
|
||||||
|
font_family = "system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||||
|
heading_font_family = "system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||||
|
text_color = "#0b0b0b"
|
||||||
|
muted_text_color = "#52514e"
|
||||||
|
|
||||||
|
# surfaces — warm neutrals from the chart chrome
|
||||||
|
background_color = "#f4f3ef"
|
||||||
|
content_background_color = "#fcfcfb"
|
||||||
|
surface_color = "#fcfcfb"
|
||||||
|
sidebar_background_color = "#f4f3ef"
|
||||||
|
sidebar_text_color = "#0b0b0b"
|
||||||
|
border_color = "#e1e0d9"
|
||||||
|
|
||||||
|
# accents — the figures' capability blue
|
||||||
|
primary_color = "#2a78d6"
|
||||||
|
accent_color = "#2a78d6"
|
||||||
|
focus_border_color = "#2a78d6"
|
||||||
|
topbar_background_color = "#0b0b0b"
|
||||||
|
topbar_text_color = "#fcfcfb"
|
||||||
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@
|
|||||||
"> ⚠️ **Planning tool.** List rates unless overridden; not contractual pricing.\n",
|
"> ⚠️ **Planning tool.** List rates unless overridden; not contractual pricing.\n",
|
||||||
"> Site data outside NAM is **estimated — confirm with CTM**.\n",
|
"> Site data outside NAM is **estimated — confirm with CTM**.\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Same `tokencalc` library as the Streamlit app (`streamlit run app/streamlit_app.py`) —\n",
|
"Same `tokencalc` library that powers the corrected business case notebook — serve either interactively with `mercury --working-dir notebooks/` —\n",
|
||||||
"Run-All here produces identical headline numbers on default inputs."
|
"Run-All here produces identical headline numbers on default inputs."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
app = ["streamlit>=1.30"]
|
app = ["mercury>=3.2"]
|
||||||
notebook = ["jupyterlab>=4.0", "ipywidgets>=8.0"]
|
notebook = ["jupyterlab>=4.0", "ipywidgets>=8.0", "nbconvert>=7", "tabulate>=0.9"]
|
||||||
dev = ["pytest>=7.4", "mypy>=1.8"]
|
dev = ["pytest>=7.4", "mypy>=1.8"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
streamlit>=1.30
|
mercury>=3.2
|
||||||
pandas>=2.0
|
pandas>=2.0
|
||||||
numpy>=1.25
|
numpy>=1.25
|
||||||
plotly>=5.18
|
plotly>=5.18
|
||||||
@@ -6,4 +6,6 @@ openpyxl>=3.1
|
|||||||
pydantic>=2.0
|
pydantic>=2.0
|
||||||
jupyterlab>=4.0
|
jupyterlab>=4.0
|
||||||
ipywidgets>=8.0
|
ipywidgets>=8.0
|
||||||
|
nbconvert>=7
|
||||||
|
tabulate>=0.9
|
||||||
pytest>=7.4
|
pytest>=7.4
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Export the corrected business case notebook as LLM-readable report sources.
|
||||||
|
|
||||||
|
Executes the notebook fresh (widget defaults — or whatever defaults you edit in),
|
||||||
|
then writes both formats to exports/:
|
||||||
|
|
||||||
|
exports/ctm_business_case_corrected.html — human-reviewable, tables render
|
||||||
|
exports/ctm_business_case_corrected.md — leanest LLM input
|
||||||
|
|
||||||
|
Plotly figures export as JavaScript an LLM cannot read; the notebook's section-12
|
||||||
|
machine-readable appendix carries every number behind them.
|
||||||
|
|
||||||
|
Run from the project root: python scripts/export_report.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
NOTEBOOK = ROOT / "notebooks" / "ctm_business_case_corrected.ipynb"
|
||||||
|
EXPORTS = ROOT / "exports"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
EXPORTS.mkdir(exist_ok=True)
|
||||||
|
for fmt in ("html", "markdown"):
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-m", "nbconvert", "--execute",
|
||||||
|
"--to", fmt, "--output-dir", str(EXPORTS), str(NOTEBOOK)],
|
||||||
|
check=True, cwd=ROOT,
|
||||||
|
)
|
||||||
|
for p in sorted(EXPORTS.iterdir()):
|
||||||
|
if p.suffix in (".html", ".md"):
|
||||||
|
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
tokencalc — Genesys AI token cost & business case calculator core.
|
tokencalc — Genesys AI token cost & business case calculator core.
|
||||||
|
|
||||||
Pure-Python, UI-agnostic. The JupyterLab notebook and the Streamlit
|
Pure-Python, UI-agnostic. The notebooks (served interactively with
|
||||||
app are thin presentation layers over these functions.
|
Mercury) are thin presentation layers over these functions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .benefit_model import calculate_total_benefit
|
from .benefit_model import calculate_total_benefit
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ kept verbatim, with the costs the deck omitted: AI Experience token
|
|||||||
consumption, AI implementation effort (V2 LoE), and double-billing of
|
consumption, AI implementation effort (V2 LoE), and double-billing of
|
||||||
the existing platforms until their term contracts end.
|
the existing platforms until their term contracts end.
|
||||||
|
|
||||||
Single source of truth shared by the notebook
|
Single source of truth behind the deliverable notebook
|
||||||
(``notebooks/ctm_business_case_corrected.ipynb``) and the Streamlit
|
(``notebooks/ctm_business_case_corrected.ipynb``, served with
|
||||||
"Corrected Business Case" view — presentation layers hold no math.
|
Mercury) — the presentation layer holds no math.
|
||||||
|
|
||||||
Sources: ``docs/Appendix 4 - CCaaS Platform Benefit Calculations
|
Sources: ``docs/Appendix 4 - CCaaS Platform Benefit Calculations
|
||||||
(Consolidated).pptx`` (verbatim figures, deployment schedule) and
|
(Consolidated).pptx`` (verbatim figures, deployment schedule) and
|
||||||
@@ -370,7 +370,7 @@ AI_IMPL_HOURS: dict[str, tuple[float, float]] = {
|
|||||||
"STA": (800, 1_200), # topics, programs, tuning × 7 languages
|
"STA": (800, 1_200), # topics, programs, tuning × 7 languages
|
||||||
"Supervisor Copilot": (200, 400),
|
"Supervisor Copilot": (200, 400),
|
||||||
"Predictive Routing": (400, 700),
|
"Predictive Routing": (400, 700),
|
||||||
"Cross-cutting": (1_000, 1_800), # governance, PM, test env, integration
|
"Cross-cutting (PM, governance, testing, integration)": (1_000, 1_800),
|
||||||
}
|
}
|
||||||
KB_READINESS_HOURS = (500, 1_500) # prerequisite project — flagged separately
|
KB_READINESS_HOURS = (500, 1_500) # prerequisite project — flagged separately
|
||||||
STEADY_STATE_HOURS = (500, 900) # absolute h/yr, 2027-2028
|
STEADY_STATE_HOURS = (500, 900) # absolute h/yr, 2027-2028
|
||||||
@@ -387,7 +387,7 @@ def impl_feature_regions(copilot_includes_asia: bool = False) -> dict[str, list[
|
|||||||
"STA": list(REGIONS),
|
"STA": list(REGIONS),
|
||||||
"Supervisor Copilot": ["NA", "ANZ", "EMEA"], # deck: $0 SupCopilot in ASIA
|
"Supervisor Copilot": ["NA", "ANZ", "EMEA"], # deck: $0 SupCopilot in ASIA
|
||||||
"Predictive Routing": list(REGIONS),
|
"Predictive Routing": list(REGIONS),
|
||||||
"Cross-cutting": list(REGIONS),
|
"Cross-cutting (PM, governance, testing, integration)": list(REGIONS),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user