""" 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"{title}" if subtitle: t += f"
{subtitle}" 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}") 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}") # โ”€โ”€ 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"{a4.money(float(c))}", 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"{a4.money(benefit_by_year[y])}", 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"{a4.money(corrected_by_year[y])}", 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 {a4.html_money(float(cum_c.iloc[-1]))} โ€” " 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"{'+' if d >= 0 else 'โˆ’'}{a4.money(abs(d))}", 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)