feat: add GenesysCX study and fix Streamlit chart key collisions
- Add 202512_GenesysCX TEI study (config, seed data, notebooks, README) with NPV $10.8M / ROI 266% including AI-token cost line - Add explicit `key` parameter to all chart wrappers in app/components to prevent StreamlitDuplicateElementId errors when the same figure type renders across Summary/Benefits/Costs tabs - Render benefits bar and cost pie charts on their respective tabs - Add benefits_vs_costs_by_year chart wrapper
This commit is contained in:
4
studies/202512_GenesysCX/ctm-token-calculator/.gitignore
vendored
Normal file
4
studies/202512_GenesysCX/ctm-token-calculator/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
exports/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.ipynb_checkpoints/
|
||||
82
studies/202512_GenesysCX/ctm-token-calculator/README.md
Normal file
82
studies/202512_GenesysCX/ctm-token-calculator/README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# CTM Token Calculator
|
||||
|
||||
**Genesys AI Token Cost & Business Case Calculator** — interactive,
|
||||
defensible modeling of Genesys Cloud **CX 3** platform + AI feature costs
|
||||
against realistic benefit scenarios, replacing single-point vendor ROI
|
||||
outputs with sensitivity-aware **Floor / Realistic / Stretch** analysis.
|
||||
|
||||
> ⚠️ **Planning tool.** Uses published Genesys list rates unless overridden —
|
||||
> explicitly not a replacement for contractual pricing. No Genesys API
|
||||
> integration; this is a forward-looking model, not a production-consumption
|
||||
> dashboard.
|
||||
|
||||
## CTM context
|
||||
|
||||
- 9 sites (NAM, EMEA, AUZ, 6× APAC), **2,088 contracted named users**
|
||||
- NAM volumes from CTM discovery; **all other site data is estimated —
|
||||
confirm with CTM** (flagged throughout the UI)
|
||||
- Cost takeouts include the NICE IEX (NAM) retirement placeholder ($1.3M/yr,
|
||||
estimated)
|
||||
- Every meter carries a confidence flag: 🟢 confirmed (published rate) ·
|
||||
🟡 estimated · 🔴 unknown (working default, rate not yet sourced)
|
||||
|
||||
## Install & run
|
||||
|
||||
```bash
|
||||
cd ctm-token-calculator
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Streamlit app (7 pages: Inputs → Export)
|
||||
streamlit run app/streamlit_app.py
|
||||
|
||||
# JupyterLab notebook variant (same numbers, same library)
|
||||
jupyter lab notebooks/ctm_token_calculator.ipynb
|
||||
|
||||
# Tests
|
||||
pytest
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
All math lives in the pure-Python `tokencalc/` library; the notebook and
|
||||
Streamlit app are thin presentation layers calling the same functions —
|
||||
Run-All in the notebook produces identical headline numbers to the app on
|
||||
default inputs.
|
||||
|
||||
| Module | Purpose |
|
||||
|---|---|
|
||||
| `meters.py` | Token meter + pricing dataclasses, confidence enum |
|
||||
| `defaults.py` | Genesys meter catalogue, CTM sites/takeouts/phasing, CX 3 rate ($111.28/user/mo) |
|
||||
| `inputs.py` | Validated input dataclasses (sites, feature scopes, takeouts) |
|
||||
| `scenarios.py` | Floor/Realistic/Stretch + benefit params (Genesys claim vs pressure-tested) |
|
||||
| `cost_model.py` | Platform, per-user AI, consumption AI cost engines |
|
||||
| `benefit_model.py` | AHT/ACW/email/deflection/STA benefit engines |
|
||||
| `business_case.py` | 3-year P&L, NPV @ 8%, payback, ROI |
|
||||
| `exports.py` | Multi-sheet Excel, CSV, JSON scenario save/load |
|
||||
|
||||
### Correctness rules encoded in the model
|
||||
|
||||
1. **Agent Copilot covers Supervisor AI Summary** — AI Summary & Insights is
|
||||
never billed at sites where Copilot is enabled (Copilot's 40 tokens/user/mo
|
||||
includes summarization). Implemented and tested.
|
||||
2. **Billing-style rounding** — monthly consumption token totals are rounded
|
||||
up (`ceil`) per site before pricing; per-user totals are exact.
|
||||
3. **Regional pricing** — every site resolves its token rate through its
|
||||
pricing region (US/EU/AU/APAC); nothing is hardcoded to US.
|
||||
4. **Adoption ramp** — consumption features ramp (default Y1 = 70%); per-user
|
||||
licences are paid in full from their phase year. Phasing is per-site,
|
||||
per-feature, per-phase (1/2/3/off).
|
||||
|
||||
### Verified reference numbers
|
||||
|
||||
- STA: 2,088 users × 30 tokens × 12 × $1 = **$751,680** ✓ (test)
|
||||
- Agent Copilot: 2,088 × 40 × 12 × $1 = **$1,002,240** ✓ (test)
|
||||
- NPV hand-check: 100/yr × 3 @ 8% = 257.710 ✓ (test)
|
||||
|
||||
## Auditability
|
||||
|
||||
Every number traces to an input and a meter: cost rows carry the feature,
|
||||
scope (sites), and confidence; benefit rows carry the driver line and scope;
|
||||
the Excel export includes input, meter, cost-detail, benefit-detail, business
|
||||
case, and three-scenario comparison sheets.
|
||||
@@ -0,0 +1,576 @@
|
||||
"""
|
||||
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 (
|
||||
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", [
|
||||
"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:,}."
|
||||
)
|
||||
|
||||
|
||||
# ── Page 1: Inputs ───────────────────────────────────────────────────
|
||||
|
||||
if 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"):
|
||||
s, t, sc = 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)
|
||||
File diff suppressed because one or more lines are too long
31
studies/202512_GenesysCX/ctm-token-calculator/pyproject.toml
Normal file
31
studies/202512_GenesysCX/ctm-token-calculator/pyproject.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ctm-token-calculator"
|
||||
version = "0.1.0"
|
||||
description = "Genesys AI Token Cost & Business Case Calculator (CTM)"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"pandas>=2.0",
|
||||
"numpy>=1.25",
|
||||
"plotly>=5.18",
|
||||
"openpyxl>=3.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
app = ["streamlit>=1.30"]
|
||||
notebook = ["jupyterlab>=4.0", "ipywidgets>=8.0"]
|
||||
dev = ["pytest>=7.4", "mypy>=1.8"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["tokencalc*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
packages = ["tokencalc"]
|
||||
@@ -0,0 +1,9 @@
|
||||
streamlit>=1.30
|
||||
pandas>=2.0
|
||||
numpy>=1.25
|
||||
plotly>=5.18
|
||||
openpyxl>=3.1
|
||||
pydantic>=2.0
|
||||
jupyterlab>=4.0
|
||||
ipywidgets>=8.0
|
||||
pytest>=7.4
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Benefit engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc.benefit_model import (
|
||||
calculate_acw_summarization_benefit,
|
||||
calculate_email_ai_benefit,
|
||||
calculate_total_benefit,
|
||||
)
|
||||
from tokencalc.defaults import CTM_DEFAULT_FEATURE_SCOPES, CTM_DEFAULT_SITES
|
||||
from tokencalc.inputs import WORKING_SECONDS_PER_YEAR, FeatureScope, SiteInput
|
||||
|
||||
ALL_SITES = [s.site_name for s in CTM_DEFAULT_SITES]
|
||||
|
||||
|
||||
def _small_site() -> SiteInput:
|
||||
return SiteInput(
|
||||
"Small", "US", agents=10, supervisors=1,
|
||||
voice_volume_monthly=10_000, email_volume_monthly=1_000,
|
||||
chat_volume_monthly=0, sms_volume_monthly=0,
|
||||
voice_aht_seconds=300, email_aht_seconds=600,
|
||||
chat_aht_seconds=480, voice_acw_seconds=60,
|
||||
fully_loaded_agent_cost_annual=74_880, # → $0.01/second exactly
|
||||
fully_loaded_supervisor_cost_annual=95_000,
|
||||
)
|
||||
|
||||
|
||||
def test_acw_benefit_hand_check():
|
||||
"""10,000 calls × 12 × 70% eligible × 60s ACW × 40% reduction ×
|
||||
50% Y1 realization × $0.01/s = $10,080."""
|
||||
site = _small_site()
|
||||
assert site.agent_cost_per_second == pytest.approx(0.01)
|
||||
df = calculate_acw_summarization_benefit(
|
||||
[site], FeatureScope("Agent Copilot", ["Small"]), "realistic", year=1,
|
||||
)
|
||||
expected = 10_000 * 12 * 0.70 * 60 * 0.40 * 0.50 * 0.01
|
||||
assert df["annual_value"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_email_benefit_split():
|
||||
site = _small_site()
|
||||
df = calculate_email_ai_benefit(
|
||||
[site], FeatureScope("Email AI (Auto-Respond)", ["Small"]),
|
||||
"realistic", year=1,
|
||||
)
|
||||
lines = set(df["benefit_line"])
|
||||
assert lines == {
|
||||
"Email Auto-Respond (displaced handling)",
|
||||
"Email Auto-Suggest (drafting time)",
|
||||
}
|
||||
# auto-respond: 1,000×12 × 20% × 600s × 50% × $0.01 = $7,200
|
||||
respond = df[df["benefit_line"].str.contains("Respond")]["annual_value"].sum()
|
||||
assert respond == pytest.approx(7_200)
|
||||
|
||||
|
||||
def test_scenarios_produce_distinct_benefits():
|
||||
totals = {
|
||||
name: calculate_total_benefit(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, name, year=2
|
||||
)["annual_value"].sum()
|
||||
for name in ("floor", "realistic", "stretch")
|
||||
}
|
||||
assert totals["floor"] < totals["realistic"] < totals["stretch"]
|
||||
|
||||
|
||||
def test_claim_exceeds_realistic():
|
||||
realistic = calculate_total_benefit(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, "realistic", year=1,
|
||||
params="realistic",
|
||||
)["annual_value"].sum()
|
||||
claim = calculate_total_benefit(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, "realistic", year=1,
|
||||
params="claim",
|
||||
)["annual_value"].sum()
|
||||
assert claim > realistic
|
||||
|
||||
|
||||
def test_benefits_ramp_by_year():
|
||||
by_year = [
|
||||
calculate_total_benefit(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, "realistic", year=y
|
||||
)["annual_value"].sum()
|
||||
for y in (1, 2, 3)
|
||||
]
|
||||
assert by_year[0] < by_year[1] < by_year[2]
|
||||
|
||||
|
||||
def test_zero_volume_site_is_safe():
|
||||
site = SiteInput(
|
||||
"Empty", "US", agents=0, supervisors=0,
|
||||
voice_volume_monthly=0, email_volume_monthly=0,
|
||||
chat_volume_monthly=0, sms_volume_monthly=0,
|
||||
voice_aht_seconds=300, email_aht_seconds=600,
|
||||
chat_aht_seconds=480, voice_acw_seconds=0,
|
||||
fully_loaded_agent_cost_annual=0,
|
||||
fully_loaded_supervisor_cost_annual=0,
|
||||
)
|
||||
df = calculate_total_benefit(
|
||||
[site], [FeatureScope("Agent Copilot", ["Empty"])], "realistic", year=1,
|
||||
)
|
||||
assert df["annual_value"].sum() == 0
|
||||
|
||||
|
||||
def test_working_seconds_constant():
|
||||
assert WORKING_SECONDS_PER_YEAR == 2_080 * 3_600
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Business case maths + exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc.business_case import build_business_case, npv, payback_years
|
||||
from tokencalc.defaults import (
|
||||
CTM_DEFAULT_FEATURE_SCOPES,
|
||||
CTM_DEFAULT_SITES,
|
||||
CTM_DEFAULT_TAKEOUTS,
|
||||
DEFAULT_METERS,
|
||||
DEFAULT_PRICING,
|
||||
)
|
||||
from tokencalc.exports import (
|
||||
export_excel,
|
||||
scenario_state_from_json,
|
||||
scenario_state_to_json,
|
||||
)
|
||||
|
||||
|
||||
def test_npv_hand_check():
|
||||
"""100/yr for 3 years @ 8%: 92.593 + 85.734 + 79.383 = 257.710."""
|
||||
assert npv([100, 100, 100], 0.08) == pytest.approx(257.710, abs=0.001)
|
||||
|
||||
|
||||
def test_payback_interpolation():
|
||||
# -100 in Y1, +200 in Y2 → breakeven halfway through Y2 = 1.5 years
|
||||
assert payback_years([-100, 200, 0]) == pytest.approx(1.5)
|
||||
assert payback_years([-100, -100, -100]) is None
|
||||
assert payback_years([50, 50, 50]) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def _case(scenario="realistic", **kw):
|
||||
return build_business_case(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, DEFAULT_METERS,
|
||||
DEFAULT_PRICING, CTM_DEFAULT_TAKEOUTS, scenario, **kw,
|
||||
)
|
||||
|
||||
|
||||
def test_business_case_shape():
|
||||
case = _case()
|
||||
assert set(case) == {
|
||||
"cost_by_year", "benefit_by_year", "takeouts_by_year",
|
||||
"net_by_year", "cumulative_net", "npv",
|
||||
"payback_period_years", "roi_3yr",
|
||||
}
|
||||
for key in ("cost_by_year", "benefit_by_year", "net_by_year"):
|
||||
assert {"Y1", "Y2", "Y3"} <= set(case[key].columns)
|
||||
|
||||
|
||||
def test_net_consistency():
|
||||
"""NET row must equal benefits + takeouts − costs, per year."""
|
||||
case = _case()
|
||||
nb = case["net_by_year"].set_index("line")
|
||||
for y in ("Y1", "Y2", "Y3"):
|
||||
assert nb.loc["NET", y] == pytest.approx(
|
||||
nb.loc["TOTAL BENEFITS", y]
|
||||
+ nb.loc["TOTAL TAKEOUTS", y]
|
||||
- nb.loc["TOTAL COSTS", y]
|
||||
)
|
||||
# cumulative is a running sum of NET
|
||||
assert nb.loc["Cumulative net", "Y3"] == pytest.approx(
|
||||
sum(nb.loc["NET", y] for y in ("Y1", "Y2", "Y3"))
|
||||
)
|
||||
|
||||
|
||||
def test_npv_matches_net_rows():
|
||||
case = _case()
|
||||
nb = case["net_by_year"].set_index("line")
|
||||
net = [nb.loc["NET", y] for y in ("Y1", "Y2", "Y3")]
|
||||
assert case["npv"] == pytest.approx(npv(net, 0.08))
|
||||
|
||||
|
||||
def test_three_scenarios_distinct():
|
||||
npvs = {s: _case(s)["npv"] for s in ("floor", "realistic", "stretch")}
|
||||
assert len({round(v) for v in npvs.values()}) == 3
|
||||
assert npvs["floor"] < npvs["realistic"] < npvs["stretch"]
|
||||
|
||||
|
||||
def test_implementation_amortization():
|
||||
base = _case()
|
||||
with_impl = _case(implementation_cost=900_000)
|
||||
nb, nb2 = (
|
||||
c["net_by_year"].set_index("line") for c in (base, with_impl)
|
||||
)
|
||||
for y in ("Y1", "Y2", "Y3"):
|
||||
assert nb2.loc["TOTAL COSTS", y] == pytest.approx(
|
||||
nb.loc["TOTAL COSTS", y] + 300_000
|
||||
)
|
||||
|
||||
|
||||
def test_excel_export_readable(tmp_path):
|
||||
case = _case()
|
||||
path = export_excel(
|
||||
{
|
||||
"Business Case": case["net_by_year"],
|
||||
"Costs": case["cost_by_year"],
|
||||
"Benefits": case["benefit_by_year"],
|
||||
},
|
||||
tmp_path / "ctm.xlsx",
|
||||
)
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(path)
|
||||
assert set(wb.sheetnames) == {"Business Case", "Costs", "Benefits"}
|
||||
|
||||
|
||||
def test_scenario_json_roundtrip(tmp_path):
|
||||
p = tmp_path / "state.json"
|
||||
scenario_state_to_json(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_TAKEOUTS, CTM_DEFAULT_FEATURE_SCOPES, p
|
||||
)
|
||||
sites, takeouts, scopes = scenario_state_from_json(p)
|
||||
assert [s.site_name for s in sites] == [s.site_name for s in CTM_DEFAULT_SITES]
|
||||
assert takeouts[0].annual_cost == CTM_DEFAULT_TAKEOUTS[0].annual_cost
|
||||
assert scopes[0].adoption_curve == CTM_DEFAULT_FEATURE_SCOPES[0].adoption_curve
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Cost engine — including the spec's acceptance numbers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc.cost_model import (
|
||||
calculate_consumption_ai_cost,
|
||||
calculate_per_user_ai_cost,
|
||||
calculate_platform_license_cost,
|
||||
calculate_total_cost,
|
||||
)
|
||||
from tokencalc.defaults import (
|
||||
CONTRACTED_NAMED_USERS,
|
||||
CTM_DEFAULT_FEATURE_SCOPES,
|
||||
CTM_DEFAULT_SITES,
|
||||
DEFAULT_METERS,
|
||||
DEFAULT_PRICING,
|
||||
)
|
||||
from tokencalc.inputs import FeatureScope, SiteInput
|
||||
from tokencalc.scenarios import get_scenario
|
||||
|
||||
ALL_SITES = [s.site_name for s in CTM_DEFAULT_SITES]
|
||||
|
||||
|
||||
def _scope(feature, sites=None, **kw):
|
||||
return FeatureScope(feature, sites or ALL_SITES, **kw)
|
||||
|
||||
|
||||
def test_default_sites_match_contracted_users():
|
||||
assert sum(s.named_users for s in CTM_DEFAULT_SITES) == CONTRACTED_NAMED_USERS
|
||||
|
||||
|
||||
def test_sta_acceptance_number():
|
||||
"""2,088 users × 30 tokens × 12 months × $1 = $751,680."""
|
||||
df = calculate_per_user_ai_cost(
|
||||
CTM_DEFAULT_SITES, _scope("Speech & Text Analytics"),
|
||||
DEFAULT_METERS["Speech & Text Analytics"], DEFAULT_PRICING,
|
||||
)
|
||||
assert df["annual_cost"].sum() == pytest.approx(751_680)
|
||||
|
||||
|
||||
def test_agent_copilot_acceptance_number():
|
||||
"""2,088 users × 40 tokens × 12 months × $1 = $1,002,240."""
|
||||
df = calculate_per_user_ai_cost(
|
||||
CTM_DEFAULT_SITES, _scope("Agent Copilot"),
|
||||
DEFAULT_METERS["Agent Copilot"], DEFAULT_PRICING,
|
||||
)
|
||||
assert df["annual_cost"].sum() == pytest.approx(1_002_240)
|
||||
|
||||
|
||||
def test_per_user_not_active_before_phase():
|
||||
df = calculate_per_user_ai_cost(
|
||||
CTM_DEFAULT_SITES, _scope("AI Translate", phase=3),
|
||||
DEFAULT_METERS["AI Translate"], DEFAULT_PRICING, year=2,
|
||||
)
|
||||
assert df["annual_cost"].sum() == 0
|
||||
|
||||
|
||||
def test_copilot_covers_supervisor_summary():
|
||||
"""Rule 1: AI Summary cost is zero at Copilot sites."""
|
||||
scenario = get_scenario("realistic")
|
||||
total = calculate_total_cost(
|
||||
CTM_DEFAULT_SITES,
|
||||
[
|
||||
_scope("Agent Copilot"),
|
||||
_scope("AI Summary & Insights"),
|
||||
],
|
||||
DEFAULT_METERS, DEFAULT_PRICING, scenario, year=1,
|
||||
include_platform=False,
|
||||
)
|
||||
summary_row = total[total["cost_line"] == "AI Summary & Insights"].iloc[0]
|
||||
assert summary_row["annual_cost"] == 0
|
||||
# Without Copilot the same line costs real money.
|
||||
total2 = calculate_total_cost(
|
||||
CTM_DEFAULT_SITES,
|
||||
[_scope("AI Summary & Insights")],
|
||||
DEFAULT_METERS, DEFAULT_PRICING, scenario, year=1,
|
||||
include_platform=False,
|
||||
)
|
||||
assert total2[total2["cost_line"] == "AI Summary & Insights"].iloc[0][
|
||||
"annual_cost"
|
||||
] > 0
|
||||
|
||||
|
||||
def test_consumption_tokens_rounded_up_monthly():
|
||||
"""Rule 2: ceil on monthly site token totals."""
|
||||
site = SiteInput(
|
||||
"Tiny", "US", agents=5, supervisors=0,
|
||||
voice_volume_monthly=100, email_volume_monthly=0,
|
||||
chat_volume_monthly=0, sms_volume_monthly=0,
|
||||
voice_aht_seconds=300, email_aht_seconds=600,
|
||||
chat_aht_seconds=480, voice_acw_seconds=60,
|
||||
fully_loaded_agent_cost_annual=65_000,
|
||||
fully_loaded_supervisor_cost_annual=95_000,
|
||||
)
|
||||
# realistic: 100 calls × 35% × 1.5 min = 52.5 min × (1/17) = 3.088
|
||||
# tokens × 70% Y1 ramp applied to units → 36.75 min → 2.16 tokens → ceil 3
|
||||
df = calculate_consumption_ai_cost(
|
||||
[site], FeatureScope("Voice Bot", ["Tiny"]),
|
||||
DEFAULT_METERS["Voice Bot"], "realistic", DEFAULT_PRICING, year=1,
|
||||
)
|
||||
assert df.iloc[0]["tokens_monthly"] == 3
|
||||
assert df.iloc[0]["annual_cost"] == pytest.approx(3 * 12 * 1.0)
|
||||
|
||||
|
||||
def test_regional_pricing_not_hardcoded():
|
||||
pricing = dict(DEFAULT_PRICING)
|
||||
from tokencalc.meters import TokenPricing
|
||||
|
||||
pricing["APAC"] = TokenPricing(region="APAC", list_rate_per_token=2.0)
|
||||
apac_site = next(s for s in CTM_DEFAULT_SITES if s.region_pricing == "APAC")
|
||||
df = calculate_per_user_ai_cost(
|
||||
[apac_site], _scope("Speech & Text Analytics", [apac_site.site_name]),
|
||||
DEFAULT_METERS["Speech & Text Analytics"], pricing,
|
||||
)
|
||||
expected = apac_site.named_users * 30 * 12 * 2.0
|
||||
assert df["annual_cost"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_year1_consumption_ramp_default_70pct():
|
||||
sc = get_scenario("realistic")
|
||||
assert sc.cost_realization(1) == pytest.approx(0.70)
|
||||
assert sc.cost_realization(2) == 1.0
|
||||
|
||||
|
||||
def test_platform_license_cost():
|
||||
df = calculate_platform_license_cost(CTM_DEFAULT_SITES)
|
||||
expected = CONTRACTED_NAMED_USERS * 111.28 * 12
|
||||
assert df["annual_cost"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_total_cost_default_scopes_runs_all_years():
|
||||
for year in (1, 2, 3):
|
||||
df = calculate_total_cost(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES,
|
||||
DEFAULT_METERS, DEFAULT_PRICING, "realistic", year,
|
||||
)
|
||||
assert (df["annual_cost"] >= 0).all()
|
||||
assert {"cost_line", "scope", "annual_cost", "confidence"} <= set(df.columns)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Meter catalogue integrity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc.defaults import DEFAULT_METERS, DEFAULT_PRICING
|
||||
from tokencalc.meters import Confidence, MeterType, TokenMeter, TokenPricing
|
||||
|
||||
|
||||
def test_all_spec_meters_present():
|
||||
expected = {
|
||||
"Voice Bot", "Virtual Agent (legacy)", "Agentic Virtual Agent",
|
||||
"AI Summary & Insights", "Direct Messaging", "Social Listening",
|
||||
"Social Responses", "Speech & Text Analytics", "Agent Copilot",
|
||||
"Email AI (Auto-Suggest)", "Email AI (Auto-Respond)", "AI Translate",
|
||||
}
|
||||
assert expected == set(DEFAULT_METERS)
|
||||
|
||||
|
||||
def test_confirmed_rates():
|
||||
m = DEFAULT_METERS
|
||||
assert m["Voice Bot"].units_per_token == 17
|
||||
assert m["Voice Bot"].tokens_per_unit == pytest.approx(0.0588, abs=1e-3)
|
||||
assert m["Agentic Virtual Agent"].tokens_per_unit == 1.2
|
||||
assert m["AI Summary & Insights"].tokens_per_unit == 0.02
|
||||
assert m["Direct Messaging"].units_per_token == 400
|
||||
assert m["Speech & Text Analytics"].tokens_per_unit == 30
|
||||
assert m["Agent Copilot"].tokens_per_unit == 40
|
||||
|
||||
|
||||
def test_unknown_meters_flagged():
|
||||
unknown = {f for f, m in DEFAULT_METERS.items() if m.confidence is Confidence.UNKNOWN}
|
||||
assert unknown == {
|
||||
"Email AI (Auto-Suggest)", "Email AI (Auto-Respond)", "AI Translate"
|
||||
}
|
||||
assert Confidence.UNKNOWN.icon == "🔴"
|
||||
assert Confidence.CONFIRMED.icon == "🟢"
|
||||
|
||||
|
||||
def test_inverse_consistency_validated():
|
||||
with pytest.raises(ValueError, match="not inverses"):
|
||||
TokenMeter(
|
||||
feature="Bad", meter_type=MeterType.PER_MINUTE,
|
||||
units_per_token=10, tokens_per_unit=0.5,
|
||||
confidence=Confidence.ESTIMATED, notes="",
|
||||
)
|
||||
|
||||
|
||||
def test_every_confirmed_meter_has_source_url():
|
||||
for m in DEFAULT_METERS.values():
|
||||
if m.confidence is Confidence.CONFIRMED:
|
||||
assert m.source_url, f"{m.feature} missing source URL"
|
||||
|
||||
|
||||
def test_pricing_effective_rate():
|
||||
p = TokenPricing(region="US", list_rate_per_token=1.0,
|
||||
contracted_rate_per_token=0.85)
|
||||
assert p.effective_rate(use_contracted=False) == 1.0
|
||||
assert p.effective_rate(use_contracted=True) == 0.85
|
||||
# no contracted rate → falls back to list
|
||||
assert DEFAULT_PRICING["US"].effective_rate(use_contracted=True) == 1.0
|
||||
|
||||
|
||||
def test_all_regions_priced():
|
||||
assert set(DEFAULT_PRICING) == {"US", "EU", "AU", "APAC"}
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
tokencalc — Genesys AI token cost & business case calculator core.
|
||||
|
||||
Pure-Python, UI-agnostic. The JupyterLab notebook and the Streamlit
|
||||
app are thin presentation layers over these functions.
|
||||
"""
|
||||
|
||||
from .benefit_model import calculate_total_benefit
|
||||
from .business_case import build_business_case, npv, payback_years
|
||||
from .cost_model import (
|
||||
calculate_consumption_ai_cost,
|
||||
calculate_per_user_ai_cost,
|
||||
calculate_platform_license_cost,
|
||||
calculate_total_cost,
|
||||
)
|
||||
from .defaults import (
|
||||
CONTRACTED_NAMED_USERS,
|
||||
CTM_DEFAULT_FEATURE_SCOPES,
|
||||
CTM_DEFAULT_ROLLOUT,
|
||||
CTM_DEFAULT_SITES,
|
||||
CTM_DEFAULT_TAKEOUTS,
|
||||
DEFAULT_METERS,
|
||||
DEFAULT_PRICING,
|
||||
PLATFORM_RATE_PER_USER_MONTHLY,
|
||||
)
|
||||
from .rollout import NO_ROLLOUT, RolloutPlan
|
||||
from .exports import (
|
||||
export_csv,
|
||||
export_excel,
|
||||
meters_dataframe,
|
||||
scenario_state_from_json,
|
||||
scenario_state_to_json,
|
||||
sites_dataframe,
|
||||
)
|
||||
from .inputs import CostTakeout, FeatureScope, SiteInput
|
||||
from .meters import Confidence, MeterType, TokenMeter, TokenPricing
|
||||
from .scenarios import BENEFIT_PARAMS, SCENARIOS, Scenario, get_scenario
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"BENEFIT_PARAMS",
|
||||
"CONTRACTED_NAMED_USERS",
|
||||
"CTM_DEFAULT_FEATURE_SCOPES",
|
||||
"CTM_DEFAULT_ROLLOUT",
|
||||
"CTM_DEFAULT_SITES",
|
||||
"CTM_DEFAULT_TAKEOUTS",
|
||||
"Confidence",
|
||||
"CostTakeout",
|
||||
"DEFAULT_METERS",
|
||||
"DEFAULT_PRICING",
|
||||
"FeatureScope",
|
||||
"MeterType",
|
||||
"NO_ROLLOUT",
|
||||
"PLATFORM_RATE_PER_USER_MONTHLY",
|
||||
"RolloutPlan",
|
||||
"SCENARIOS",
|
||||
"Scenario",
|
||||
"SiteInput",
|
||||
"TokenMeter",
|
||||
"TokenPricing",
|
||||
"build_business_case",
|
||||
"calculate_consumption_ai_cost",
|
||||
"calculate_per_user_ai_cost",
|
||||
"calculate_platform_license_cost",
|
||||
"calculate_total_benefit",
|
||||
"calculate_total_cost",
|
||||
"export_csv",
|
||||
"export_excel",
|
||||
"get_scenario",
|
||||
"meters_dataframe",
|
||||
"npv",
|
||||
"payback_years",
|
||||
"scenario_state_from_json",
|
||||
"scenario_state_to_json",
|
||||
"sites_dataframe",
|
||||
]
|
||||
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
Benefit calculation engine.
|
||||
|
||||
All benefits convert saved handle-time seconds into dollars via each
|
||||
site's fully-loaded labour rate per working second. Reduction
|
||||
percentages come from :data:`tokencalc.scenarios.BENEFIT_PARAMS` —
|
||||
``realistic`` (pressure-tested) by default; pass ``params="claim"``
|
||||
to reproduce the Genesys ROI-doc figures for side-by-side comparison.
|
||||
|
||||
Every figure scales by the scenario's year realization ramp.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .inputs import FeatureScope, SiteInput
|
||||
from .meters import Confidence
|
||||
from .rollout import NO_ROLLOUT, RolloutPlan
|
||||
from .scenarios import BENEFIT_PARAMS, Scenario, get_scenario
|
||||
|
||||
MONTHS_PER_YEAR = 12
|
||||
|
||||
|
||||
def _param(name: str, params: str) -> float:
|
||||
return BENEFIT_PARAMS[name][params]
|
||||
|
||||
|
||||
def _scope_for(feature_scopes: list[FeatureScope] | FeatureScope,
|
||||
feature: str) -> FeatureScope | None:
|
||||
if isinstance(feature_scopes, FeatureScope):
|
||||
return feature_scopes if feature_scopes.feature == feature else None
|
||||
return next((s for s in feature_scopes if s.feature == feature), None)
|
||||
|
||||
|
||||
def _df(rows: list[dict]) -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
rows, columns=["benefit_line", "scope", "annual_value", "confidence"]
|
||||
)
|
||||
|
||||
|
||||
def calculate_voice_handle_time_benefit(
|
||||
sites: list[SiteInput],
|
||||
feature_scope: FeatureScope,
|
||||
scenario: str | Scenario,
|
||||
year: int,
|
||||
params: str = "realistic",
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""AHT reduction from knowledge surfacing (Agent Copilot).
|
||||
|
||||
Benefit = volume × eligibility × AHT × reduction% × labour rate.
|
||||
"""
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
ro = rollout or NO_ROLLOUT
|
||||
reduction = _param("voice_aht_knowledge_reduction", params)
|
||||
realization = sc.realization(year)
|
||||
rows = []
|
||||
for s in sites:
|
||||
if not feature_scope.active(s.site_name, year):
|
||||
continue
|
||||
eligibility = (
|
||||
feature_scope.eligibility_pct
|
||||
if feature_scope.eligibility_pct is not None
|
||||
else sc.voice_knowledge_eligibility
|
||||
)
|
||||
seconds_saved = (
|
||||
s.voice_volume_monthly * MONTHS_PER_YEAR
|
||||
* eligibility * s.voice_aht_seconds * reduction * realization
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"benefit_line": "Voice AHT (knowledge surfacing)",
|
||||
"scope": s.site_name,
|
||||
"annual_value": seconds_saved * s.agent_cost_per_second
|
||||
* ro.fraction_live(s.site_name, year),
|
||||
"confidence": Confidence.ESTIMATED.value,
|
||||
}
|
||||
)
|
||||
return _df(rows)
|
||||
|
||||
|
||||
def calculate_acw_summarization_benefit(
|
||||
sites: list[SiteInput],
|
||||
feature_scope: FeatureScope,
|
||||
scenario: str | Scenario,
|
||||
year: int,
|
||||
params: str = "realistic",
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""ACW eliminated by auto-summarization (Copilot / AI Summary)."""
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
ro = rollout or NO_ROLLOUT
|
||||
reduction = _param("voice_acw_reduction", params)
|
||||
realization = sc.realization(year)
|
||||
rows = []
|
||||
for s in sites:
|
||||
if not feature_scope.active(s.site_name, year):
|
||||
continue
|
||||
eligibility = (
|
||||
feature_scope.eligibility_pct
|
||||
if feature_scope.eligibility_pct is not None
|
||||
else sc.voice_summarization_eligibility
|
||||
)
|
||||
seconds_saved = (
|
||||
s.voice_volume_monthly * MONTHS_PER_YEAR
|
||||
* eligibility * s.voice_acw_seconds * reduction * realization
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"benefit_line": "Voice ACW (summarization)",
|
||||
"scope": s.site_name,
|
||||
"annual_value": seconds_saved * s.agent_cost_per_second
|
||||
* ro.fraction_live(s.site_name, year),
|
||||
"confidence": Confidence.ESTIMATED.value,
|
||||
}
|
||||
)
|
||||
return _df(rows)
|
||||
|
||||
|
||||
def calculate_email_ai_benefit(
|
||||
sites: list[SiteInput],
|
||||
feature_scope: FeatureScope,
|
||||
scenario: str | Scenario,
|
||||
year: int,
|
||||
params: str = "realistic",
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Email Auto-Respond (full displacement at the respond rate) plus
|
||||
Auto-Suggest (time saving × acceptance on the remainder)."""
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
ro = rollout or NO_ROLLOUT
|
||||
suggest_saving = _param("email_auto_suggest_time_saving", params)
|
||||
realization = sc.realization(year)
|
||||
rows = []
|
||||
for s in sites:
|
||||
if not feature_scope.active(s.site_name, year):
|
||||
continue
|
||||
respond_rate = (
|
||||
feature_scope.deflection_target
|
||||
if feature_scope.deflection_target is not None
|
||||
else sc.email_auto_respond_rate
|
||||
)
|
||||
annual_emails = s.email_volume_monthly * MONTHS_PER_YEAR
|
||||
respond_seconds = (
|
||||
annual_emails * respond_rate * s.email_aht_seconds * realization
|
||||
)
|
||||
suggest_seconds = (
|
||||
annual_emails * (1 - respond_rate)
|
||||
* sc.email_auto_suggest_acceptance * s.email_aht_seconds
|
||||
* suggest_saving * realization
|
||||
)
|
||||
rate = s.agent_cost_per_second
|
||||
rows.append(
|
||||
{
|
||||
"benefit_line": "Email Auto-Respond (displaced handling)",
|
||||
"scope": s.site_name,
|
||||
"annual_value": respond_seconds * rate
|
||||
* ro.fraction_live(s.site_name, year),
|
||||
"confidence": Confidence.UNKNOWN.value, # meter rate unsourced
|
||||
}
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"benefit_line": "Email Auto-Suggest (drafting time)",
|
||||
"scope": s.site_name,
|
||||
"annual_value": suggest_seconds * rate
|
||||
* ro.fraction_live(s.site_name, year),
|
||||
"confidence": Confidence.UNKNOWN.value,
|
||||
}
|
||||
)
|
||||
return _df(rows)
|
||||
|
||||
|
||||
def calculate_sta_benefit(
|
||||
sites: list[SiteInput],
|
||||
feature_scope: FeatureScope,
|
||||
scenario: str | Scenario,
|
||||
year: int,
|
||||
params: str = "realistic",
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""STA reduces AHT *indirectly* via coaching — small reduction with
|
||||
a realistic ramp (default 1.5% vs the 4% claim)."""
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
ro = rollout or NO_ROLLOUT
|
||||
reduction = _param("sta_aht_reduction", params)
|
||||
realization = sc.realization(year)
|
||||
rows = []
|
||||
for s in sites:
|
||||
if not feature_scope.active(s.site_name, year):
|
||||
continue
|
||||
seconds_saved = (
|
||||
s.voice_volume_monthly * MONTHS_PER_YEAR
|
||||
* s.voice_aht_seconds * reduction * realization
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"benefit_line": "STA coaching (AHT)",
|
||||
"scope": s.site_name,
|
||||
"annual_value": seconds_saved * s.agent_cost_per_second
|
||||
* ro.fraction_live(s.site_name, year),
|
||||
"confidence": Confidence.ESTIMATED.value,
|
||||
}
|
||||
)
|
||||
return _df(rows)
|
||||
|
||||
|
||||
def calculate_bot_deflection_benefit(
|
||||
sites: list[SiteInput],
|
||||
feature_scope: FeatureScope,
|
||||
scenario: str | Scenario,
|
||||
year: int,
|
||||
params: str = "realistic",
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Agent labour avoided on calls deflected to Voice Bot / Agentic VA.
|
||||
|
||||
Not in the original function list but required for a complete net
|
||||
case — deflected volume never reaches an agent, so the full AHT is
|
||||
avoided.
|
||||
"""
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
ro = rollout or NO_ROLLOUT
|
||||
realization = sc.realization(year)
|
||||
rows = []
|
||||
for s in sites:
|
||||
if not feature_scope.active(s.site_name, year):
|
||||
continue
|
||||
if feature_scope.feature == "Voice Bot":
|
||||
deflection = (
|
||||
feature_scope.deflection_target
|
||||
if feature_scope.deflection_target is not None
|
||||
else sc.voice_bot_deflection
|
||||
)
|
||||
else: # Agentic Virtual Agent
|
||||
deflection = (
|
||||
feature_scope.deflection_target
|
||||
if feature_scope.deflection_target is not None
|
||||
else sc.agentic_va_deflection
|
||||
)
|
||||
seconds_saved = (
|
||||
s.voice_volume_monthly * MONTHS_PER_YEAR
|
||||
* deflection * s.voice_aht_seconds * realization
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"benefit_line": f"{feature_scope.feature} deflection (labour avoided)",
|
||||
"scope": s.site_name,
|
||||
"annual_value": seconds_saved * s.agent_cost_per_second
|
||||
* ro.fraction_live(s.site_name, year),
|
||||
"confidence": Confidence.ESTIMATED.value,
|
||||
}
|
||||
)
|
||||
return _df(rows)
|
||||
|
||||
|
||||
def calculate_supervisor_copilot_benefit(
|
||||
sites: list[SiteInput],
|
||||
feature_scope: FeatureScope,
|
||||
scenario: str | Scenario,
|
||||
year: int,
|
||||
params: str = "realistic",
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Supervisor time reclaimed (summaries, QA triage). ESTIMATED."""
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
ro = rollout or NO_ROLLOUT
|
||||
saving = _param("supervisor_copilot_time_saving", params)
|
||||
realization = sc.realization(year)
|
||||
rows = []
|
||||
for s in sites:
|
||||
if not feature_scope.active(s.site_name, year):
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"benefit_line": "Supervisor time (AI summaries/insights)",
|
||||
"scope": s.site_name,
|
||||
"annual_value": s.supervisors
|
||||
* s.fully_loaded_supervisor_cost_annual
|
||||
* saving * realization
|
||||
* ro.fraction_live(s.site_name, year),
|
||||
"confidence": Confidence.ESTIMATED.value,
|
||||
}
|
||||
)
|
||||
return _df(rows)
|
||||
|
||||
|
||||
def calculate_predictive_routing_benefit(
|
||||
sites: list[SiteInput],
|
||||
feature_scope: FeatureScope,
|
||||
scenario: str | Scenario,
|
||||
year: int,
|
||||
params: str = "realistic",
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Predictive routing AHT effect. ESTIMATED; off unless scoped."""
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
ro = rollout or NO_ROLLOUT
|
||||
reduction = _param("predictive_routing_aht_reduction", params)
|
||||
realization = sc.realization(year)
|
||||
rows = []
|
||||
for s in sites:
|
||||
if not feature_scope.active(s.site_name, year):
|
||||
continue
|
||||
seconds_saved = (
|
||||
s.voice_volume_monthly * MONTHS_PER_YEAR
|
||||
* s.voice_aht_seconds * reduction * realization
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"benefit_line": "Predictive routing (AHT)",
|
||||
"scope": s.site_name,
|
||||
"annual_value": seconds_saved * s.agent_cost_per_second
|
||||
* ro.fraction_live(s.site_name, year),
|
||||
"confidence": Confidence.ESTIMATED.value,
|
||||
}
|
||||
)
|
||||
return _df(rows)
|
||||
|
||||
|
||||
#: Which calculator handles which feature scope.
|
||||
_BENEFIT_DISPATCH = {
|
||||
"Agent Copilot": (
|
||||
calculate_voice_handle_time_benefit,
|
||||
calculate_acw_summarization_benefit,
|
||||
),
|
||||
"AI Summary & Insights": (), # benefit carried by Copilot where present
|
||||
"Speech & Text Analytics": (calculate_sta_benefit,),
|
||||
"Voice Bot": (calculate_bot_deflection_benefit,),
|
||||
"Agentic Virtual Agent": (calculate_bot_deflection_benefit,),
|
||||
"Email AI (Auto-Respond)": (calculate_email_ai_benefit,),
|
||||
"Predictive Routing": (calculate_predictive_routing_benefit,),
|
||||
}
|
||||
|
||||
|
||||
def calculate_total_benefit(
|
||||
sites: list[SiteInput],
|
||||
feature_scopes: list[FeatureScope],
|
||||
scenario: str | Scenario,
|
||||
year: int,
|
||||
params: str = "realistic",
|
||||
include_supervisor_benefit: bool = True,
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""All benefit lines for one scenario-year, aggregated per line.
|
||||
|
||||
Returns DataFrame: benefit_line, scope, annual_value, confidence.
|
||||
"""
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
frames: list[pd.DataFrame] = []
|
||||
copilot_scope = _scope_for(feature_scopes, "Agent Copilot")
|
||||
|
||||
for scope in feature_scopes:
|
||||
for fn in _BENEFIT_DISPATCH.get(scope.feature, ()): # type: ignore[arg-type]
|
||||
frames.append(fn(sites, scope, sc, year, params=params, rollout=rollout))
|
||||
|
||||
if include_supervisor_benefit and copilot_scope is not None:
|
||||
frames.append(
|
||||
calculate_supervisor_copilot_benefit(
|
||||
sites, copilot_scope, sc, year, params=params, rollout=rollout
|
||||
)
|
||||
)
|
||||
|
||||
frames = [f for f in frames if not f.empty]
|
||||
if not frames:
|
||||
return _df([])
|
||||
detail = pd.concat(frames, ignore_index=True)
|
||||
|
||||
grouped = (
|
||||
detail.groupby("benefit_line", sort=False)
|
||||
.agg(
|
||||
scope=("scope", lambda v: ", ".join(sorted(set(v)))),
|
||||
annual_value=("annual_value", "sum"),
|
||||
confidence=("confidence", "first"),
|
||||
)
|
||||
.reset_index()
|
||||
)
|
||||
return grouped[["benefit_line", "scope", "annual_value", "confidence"]]
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Business case — combines costs, benefits, and cost takeouts into a
|
||||
3-year net view with NPV, payback, and ROI.
|
||||
|
||||
Convention: all cashflows are year-end and discounted at
|
||||
``discount_rate`` (default 8%); there is no undiscounted year-0 column
|
||||
— implementation is amortized straight-line across the analysis years
|
||||
(spec §5.6 "Implementation amort." line).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .benefit_model import calculate_total_benefit
|
||||
from .cost_model import calculate_total_cost
|
||||
from .defaults import (
|
||||
DEFAULT_DISCOUNT_RATE,
|
||||
DEFAULT_IMPLEMENTATION_COST,
|
||||
PLATFORM_RATE_PER_USER_MONTHLY,
|
||||
)
|
||||
from .inputs import CostTakeout, FeatureScope, SiteInput
|
||||
from .meters import Confidence, TokenMeter, TokenPricing
|
||||
from .rollout import RolloutPlan
|
||||
from .scenarios import Scenario, get_scenario
|
||||
|
||||
|
||||
def npv(cashflows_by_year: list[float], discount_rate: float) -> float:
|
||||
"""Year-end-discounted NPV of year-1..N cashflows."""
|
||||
return sum(
|
||||
cf / (1 + discount_rate) ** year
|
||||
for year, cf in enumerate(cashflows_by_year, start=1)
|
||||
)
|
||||
|
||||
|
||||
def payback_years(cashflows_by_year: list[float]) -> float | None:
|
||||
"""First (fractional) year cumulative net turns >= 0; None if never.
|
||||
|
||||
Cashflows are assumed evenly spread within each year.
|
||||
"""
|
||||
cumulative = 0.0
|
||||
for year, cf in enumerate(cashflows_by_year, start=1):
|
||||
if cumulative + cf >= 0 and cf != 0:
|
||||
if cumulative >= 0:
|
||||
return float(year - 1)
|
||||
return (year - 1) + (-cumulative / cf)
|
||||
cumulative += cf
|
||||
return None
|
||||
|
||||
|
||||
def build_business_case(
|
||||
sites: list[SiteInput],
|
||||
feature_scopes: list[FeatureScope],
|
||||
meters: dict[str, TokenMeter],
|
||||
pricing: dict[str, TokenPricing],
|
||||
takeouts: list[CostTakeout],
|
||||
scenario: str | Scenario,
|
||||
years: int = 3,
|
||||
discount_rate: float = DEFAULT_DISCOUNT_RATE,
|
||||
platform_rate: float = PLATFORM_RATE_PER_USER_MONTHLY,
|
||||
implementation_cost: float = DEFAULT_IMPLEMENTATION_COST,
|
||||
use_contracted: bool = False,
|
||||
benefit_params: str = "realistic",
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> dict:
|
||||
"""Returns the dict described in spec §4.3 (DataFrames + headline
|
||||
metrics). Every number traces to a cost line, benefit line, or
|
||||
takeout row in the per-year detail frames.
|
||||
"""
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
year_cols = [f"Y{y}" for y in range(1, years + 1)]
|
||||
|
||||
cost_frames, benefit_frames = {}, {}
|
||||
for y in range(1, years + 1):
|
||||
cost_frames[y] = calculate_total_cost(
|
||||
sites, feature_scopes, meters, pricing, sc, y,
|
||||
platform_rate=platform_rate, use_contracted=use_contracted,
|
||||
rollout=rollout,
|
||||
)
|
||||
benefit_frames[y] = calculate_total_benefit(
|
||||
sites, feature_scopes, sc, y, params=benefit_params,
|
||||
rollout=rollout,
|
||||
)
|
||||
|
||||
# ── cost_by_year: one row per cost line, one column per year ────
|
||||
cost_lines = list(cost_frames[1]["cost_line"])
|
||||
cost_by_year = pd.DataFrame({"line": cost_lines})
|
||||
for y in range(1, years + 1):
|
||||
cost_by_year[f"Y{y}"] = list(cost_frames[y]["annual_cost"])
|
||||
cost_by_year["confidence"] = list(cost_frames[1]["confidence"])
|
||||
if implementation_cost:
|
||||
amort = implementation_cost / years
|
||||
cost_by_year = pd.concat(
|
||||
[
|
||||
cost_by_year,
|
||||
pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"line": "Implementation (amortized)",
|
||||
**{c: amort for c in year_cols},
|
||||
"confidence": Confidence.ESTIMATED.value,
|
||||
}
|
||||
]
|
||||
),
|
||||
],
|
||||
ignore_index=True,
|
||||
)
|
||||
|
||||
# ── benefit_by_year ──────────────────────────────────────────────
|
||||
benefit_lines: list[str] = []
|
||||
for y in range(1, years + 1):
|
||||
for line in benefit_frames[y]["benefit_line"]:
|
||||
if line not in benefit_lines:
|
||||
benefit_lines.append(line)
|
||||
benefit_by_year = pd.DataFrame({"line": benefit_lines})
|
||||
for y in range(1, years + 1):
|
||||
lookup = dict(
|
||||
zip(benefit_frames[y]["benefit_line"], benefit_frames[y]["annual_value"])
|
||||
)
|
||||
benefit_by_year[f"Y{y}"] = [lookup.get(line, 0.0) for line in benefit_lines]
|
||||
conf_lookup: dict[str, str] = {}
|
||||
for y in range(1, years + 1):
|
||||
conf_lookup.update(
|
||||
dict(zip(benefit_frames[y]["benefit_line"], benefit_frames[y]["confidence"]))
|
||||
)
|
||||
benefit_by_year["confidence"] = [
|
||||
conf_lookup.get(line, Confidence.ESTIMATED.value) for line in benefit_lines
|
||||
]
|
||||
|
||||
# ── takeouts_by_year ─────────────────────────────────────────────
|
||||
takeouts_by_year = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"line": t.name,
|
||||
**{f"Y{y}": t.value_in_year(y) for y in range(1, years + 1)},
|
||||
"confidence": t.confidence.value,
|
||||
}
|
||||
for t in takeouts
|
||||
]
|
||||
)
|
||||
|
||||
# ── net + cumulative ─────────────────────────────────────────────
|
||||
total_costs = [float(cost_by_year[c].sum()) for c in year_cols]
|
||||
total_benefits = [float(benefit_by_year[c].sum()) for c in year_cols]
|
||||
total_takeouts = [
|
||||
float(takeouts_by_year[c].sum()) if not takeouts_by_year.empty else 0.0
|
||||
for c in year_cols
|
||||
]
|
||||
net = [
|
||||
b + t - c for b, t, c in zip(total_benefits, total_takeouts, total_costs)
|
||||
]
|
||||
cumulative = pd.Series(net).cumsum().tolist()
|
||||
|
||||
net_by_year = pd.DataFrame(
|
||||
{
|
||||
"line": [
|
||||
"TOTAL COSTS", "TOTAL TAKEOUTS", "TOTAL BENEFITS",
|
||||
"NET", "Cumulative net",
|
||||
],
|
||||
**{
|
||||
f"Y{y}": [
|
||||
total_costs[y - 1], total_takeouts[y - 1],
|
||||
total_benefits[y - 1], net[y - 1], cumulative[y - 1],
|
||||
]
|
||||
for y in range(1, years + 1)
|
||||
},
|
||||
}
|
||||
)
|
||||
cumulative_net = pd.DataFrame(
|
||||
{"year": list(range(1, years + 1)), "cumulative_net": cumulative}
|
||||
)
|
||||
|
||||
total_cost_sum = sum(total_costs)
|
||||
total_value_sum = sum(total_benefits) + sum(total_takeouts)
|
||||
return {
|
||||
"cost_by_year": cost_by_year,
|
||||
"benefit_by_year": benefit_by_year,
|
||||
"takeouts_by_year": takeouts_by_year,
|
||||
"net_by_year": net_by_year,
|
||||
"cumulative_net": cumulative_net,
|
||||
"npv": npv(net, discount_rate),
|
||||
"payback_period_years": payback_years(net),
|
||||
"roi_3yr": (
|
||||
(total_value_sum - total_cost_sum) / total_cost_sum
|
||||
if total_cost_sum
|
||||
else None
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
Cost calculation engine.
|
||||
|
||||
Correctness rules implemented here (see spec §4.1):
|
||||
|
||||
1. **Agent Copilot covers Supervisor AI Summary.** Where Agent Copilot
|
||||
is enabled at a site, AI Summary & Insights consumption at that site
|
||||
is forced to zero — Copilot's per-user token rate already includes
|
||||
interaction summarization. Source: Genesys Cloud AI Experience
|
||||
tokens FAQ,
|
||||
https://help.mypurecloud.com/articles/genesys-cloud-ai-experience-tokens-faqs/
|
||||
2. **Token rounding.** Genesys rounds consumption up at billing —
|
||||
``math.ceil`` is applied to each site's MONTHLY consumption token
|
||||
total before the rate. Per-user totals (users × tokens/user/month)
|
||||
are exact and not rounded.
|
||||
3. **Regional pricing.** Every site resolves its rate through its
|
||||
``region_pricing`` key — never a hardcoded US rate.
|
||||
4. **Adoption ramp.** Consumption features ramp (default Y1 = 70%);
|
||||
per-user licences are paid in full from their phase year.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .defaults import PLATFORM_RATE_PER_USER_MONTHLY
|
||||
from .inputs import FeatureScope, SiteInput
|
||||
from .meters import Confidence, MeterType, TokenMeter, TokenPricing
|
||||
from .rollout import NO_ROLLOUT, RolloutPlan
|
||||
from .scenarios import Scenario, get_scenario
|
||||
|
||||
MONTHS_PER_YEAR = 12
|
||||
|
||||
|
||||
def _rate(site: SiteInput, pricing: dict[str, TokenPricing],
|
||||
use_contracted: bool = False) -> float:
|
||||
"""Resolve the per-token rate for a site's pricing region."""
|
||||
region = pricing.get(site.region_pricing)
|
||||
if region is None:
|
||||
raise KeyError(
|
||||
f"No TokenPricing for region {site.region_pricing!r} "
|
||||
f"(site {site.site_name})"
|
||||
)
|
||||
return region.effective_rate(use_contracted)
|
||||
|
||||
|
||||
def calculate_platform_license_cost(
|
||||
sites: list[SiteInput],
|
||||
per_user_monthly_rate: float = PLATFORM_RATE_PER_USER_MONTHLY,
|
||||
year: int = 1,
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Genesys Cloud CX 3 named-user platform licences.
|
||||
|
||||
The commit bills in full from contract start regardless of site
|
||||
go-lives; the vendor ramp credit reduces YEAR 1 only (typical
|
||||
6-month ramp → 50% Y1 discount).
|
||||
Returns DataFrame: site, agents, supervisors, named_users, annual_cost.
|
||||
"""
|
||||
ro = rollout or NO_ROLLOUT
|
||||
factor = ro.platform_factor(year)
|
||||
rows = [
|
||||
{
|
||||
"site": s.site_name,
|
||||
"agents": s.agents,
|
||||
"supervisors": s.supervisors,
|
||||
"named_users": s.named_users,
|
||||
"annual_cost": s.named_users
|
||||
* per_user_monthly_rate
|
||||
* MONTHS_PER_YEAR
|
||||
* factor,
|
||||
}
|
||||
for s in sites
|
||||
]
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def calculate_per_user_ai_cost(
|
||||
sites: list[SiteInput],
|
||||
feature_scope: FeatureScope,
|
||||
meter: TokenMeter,
|
||||
pricing: dict[str, TokenPricing],
|
||||
year: int = 1,
|
||||
use_contracted: bool = False,
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Per-user-per-month AI features (STA, Agent Copilot, AI Translate,
|
||||
Email Auto-Suggest).
|
||||
|
||||
No adoption ramp and no rounding (users × tokens/user/month is
|
||||
exact) — but token usage only starts at site go-live, so the year
|
||||
bills for the months the site is live (``rollout``).
|
||||
Returns DataFrame: site, users_in_scope, tokens_monthly, annual_cost.
|
||||
"""
|
||||
if meter.meter_type is not MeterType.PER_USER_PER_MONTH:
|
||||
raise ValueError(f"{meter.feature} is not a per-user meter")
|
||||
ro = rollout or NO_ROLLOUT
|
||||
rows = []
|
||||
for s in sites:
|
||||
in_scope = feature_scope.active(s.site_name, year)
|
||||
users = s.named_users if in_scope else 0
|
||||
live_months = ro.live_months_in_year(s.site_name, year)
|
||||
tokens_monthly = users * meter.tokens_per_unit
|
||||
rows.append(
|
||||
{
|
||||
"site": s.site_name,
|
||||
"users_in_scope": users,
|
||||
"tokens_monthly": tokens_monthly,
|
||||
"annual_cost": tokens_monthly
|
||||
* live_months
|
||||
* _rate(s, pricing, use_contracted),
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _monthly_units(site: SiteInput, feature: str, scope: FeatureScope,
|
||||
scenario: Scenario) -> float:
|
||||
"""Monthly metered units for a consumption feature at one site.
|
||||
|
||||
Explicit ``scope.deflection_target`` / ``scope.eligibility_pct``
|
||||
override the scenario defaults.
|
||||
"""
|
||||
if feature == "Voice Bot":
|
||||
deflection = (
|
||||
scope.deflection_target
|
||||
if scope.deflection_target is not None
|
||||
else scenario.voice_bot_deflection
|
||||
)
|
||||
return (
|
||||
site.voice_volume_monthly * deflection * scenario.voice_bot_avg_minutes
|
||||
) # minutes
|
||||
if feature == "Agentic Virtual Agent":
|
||||
deflection = (
|
||||
scope.deflection_target
|
||||
if scope.deflection_target is not None
|
||||
else scenario.agentic_va_deflection
|
||||
)
|
||||
return site.voice_volume_monthly * deflection # interactions
|
||||
if feature == "Virtual Agent (legacy)":
|
||||
deflection = scope.deflection_target or 0.0
|
||||
return site.voice_volume_monthly * deflection
|
||||
if feature == "AI Summary & Insights":
|
||||
eligibility = (
|
||||
scope.eligibility_pct
|
||||
if scope.eligibility_pct is not None
|
||||
else scenario.voice_summarization_eligibility
|
||||
)
|
||||
return site.voice_volume_monthly * eligibility # summaries
|
||||
if feature == "Email AI (Auto-Respond)":
|
||||
rate = (
|
||||
scope.deflection_target
|
||||
if scope.deflection_target is not None
|
||||
else scenario.email_auto_respond_rate
|
||||
)
|
||||
return site.email_volume_monthly * rate # messages
|
||||
if feature in ("Direct Messaging", "Social Listening", "Social Responses"):
|
||||
eligibility = scope.eligibility_pct if scope.eligibility_pct is not None else 1.0
|
||||
return (site.chat_volume_monthly + site.sms_volume_monthly) * eligibility
|
||||
raise KeyError(f"No consumption-volume mapping for feature {feature!r}")
|
||||
|
||||
|
||||
def calculate_consumption_ai_cost(
|
||||
sites: list[SiteInput],
|
||||
feature_scope: FeatureScope,
|
||||
meter: TokenMeter,
|
||||
scenario: str | Scenario,
|
||||
pricing: dict[str, TokenPricing],
|
||||
year: int = 1,
|
||||
use_contracted: bool = False,
|
||||
excluded_sites: set[str] | None = None,
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Consumption-metered AI features (Voice Bots, Agentic VA,
|
||||
Supervisor AI Summary, Email Auto-Respond, messaging meters).
|
||||
|
||||
Applies eligibility/deflection from the scenario (or explicit scope
|
||||
overrides), the adoption ramp, billing-style ``ceil`` rounding on
|
||||
each site's monthly token total, and — with a ``rollout`` — bills
|
||||
only the months the site is live (usage starts at go-live).
|
||||
|
||||
``excluded_sites`` supports the Copilot-covers-Summary rule.
|
||||
Returns DataFrame: site, eligible_volume, tokens_monthly, annual_cost.
|
||||
"""
|
||||
if meter.meter_type is MeterType.PER_USER_PER_MONTH:
|
||||
raise ValueError(f"{meter.feature} is a per-user meter, not consumption")
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
excluded = excluded_sites or set()
|
||||
ro = rollout or NO_ROLLOUT
|
||||
|
||||
# Ramp: an explicit adoption curve wins; otherwise the scenario's
|
||||
# default consumption realization (Y1 = 70%). This models usage
|
||||
# maturity; rollout live-months model calendar availability — they
|
||||
# compound (live 6 months × 70% maturity).
|
||||
ramp = (
|
||||
feature_scope.adoption(year)
|
||||
if feature_scope.adoption_curve
|
||||
else sc.cost_realization(year)
|
||||
)
|
||||
|
||||
rows = []
|
||||
for s in sites:
|
||||
active = (
|
||||
feature_scope.active(s.site_name, year)
|
||||
and s.site_name not in excluded
|
||||
)
|
||||
units = _monthly_units(s, meter.feature, feature_scope, sc) if active else 0.0
|
||||
units *= ramp
|
||||
live_months = ro.live_months_in_year(s.site_name, year)
|
||||
# Rule 2: round each site's monthly token total UP (billing).
|
||||
tokens_monthly = math.ceil(units * meter.tokens_per_unit) if units > 0 else 0
|
||||
rows.append(
|
||||
{
|
||||
"site": s.site_name,
|
||||
"eligible_volume": units,
|
||||
"tokens_monthly": tokens_monthly,
|
||||
"annual_cost": tokens_monthly
|
||||
* live_months
|
||||
* _rate(s, pricing, use_contracted),
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def calculate_total_cost(
|
||||
sites: list[SiteInput],
|
||||
feature_scopes: list[FeatureScope],
|
||||
meters: dict[str, TokenMeter],
|
||||
pricing: dict[str, TokenPricing],
|
||||
scenario: str | Scenario,
|
||||
year: int,
|
||||
platform_rate: float = PLATFORM_RATE_PER_USER_MONTHLY,
|
||||
use_contracted: bool = False,
|
||||
include_platform: bool = True,
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""All cost lines for one scenario-year.
|
||||
|
||||
Returns DataFrame: cost_line, scope, annual_cost, confidence.
|
||||
"""
|
||||
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||
rows: list[dict] = []
|
||||
|
||||
if include_platform:
|
||||
platform = calculate_platform_license_cost(
|
||||
sites, platform_rate, year=year, rollout=rollout
|
||||
)
|
||||
ramped = rollout is not None and rollout.platform_factor(year) < 1.0
|
||||
rows.append(
|
||||
{
|
||||
"cost_line": "Genesys CX 3 platform licences"
|
||||
+ (" (ramp credit applied)" if ramped else ""),
|
||||
"scope": "all sites",
|
||||
"annual_cost": float(platform["annual_cost"].sum()),
|
||||
"confidence": Confidence.CONFIRMED.value,
|
||||
}
|
||||
)
|
||||
|
||||
# Rule 1: Agent Copilot covers Supervisor AI Summary. Sites where
|
||||
# Copilot is active this year are excluded from AI Summary billing —
|
||||
# Copilot's 40 tokens/user/month already includes summarization.
|
||||
# https://help.mypurecloud.com/articles/genesys-cloud-ai-experience-tokens-faqs/
|
||||
copilot_sites: set[str] = set()
|
||||
for scope in feature_scopes:
|
||||
if scope.feature == "Agent Copilot":
|
||||
copilot_sites |= {
|
||||
s.site_name for s in sites if scope.active(s.site_name, year)
|
||||
}
|
||||
|
||||
for scope in feature_scopes:
|
||||
meter = meters.get(scope.feature)
|
||||
if meter is None:
|
||||
raise KeyError(f"No meter defined for feature {scope.feature!r}")
|
||||
if meter.meter_type is MeterType.PER_USER_PER_MONTH:
|
||||
df = calculate_per_user_ai_cost(
|
||||
sites, scope, meter, pricing, year=year,
|
||||
use_contracted=use_contracted, rollout=rollout,
|
||||
)
|
||||
in_scope = df[df["users_in_scope"] > 0]["site"].tolist()
|
||||
else:
|
||||
excluded = (
|
||||
copilot_sites if scope.feature == "AI Summary & Insights" else None
|
||||
)
|
||||
df = calculate_consumption_ai_cost(
|
||||
sites, scope, meter, sc, pricing, year=year,
|
||||
use_contracted=use_contracted, excluded_sites=excluded,
|
||||
rollout=rollout,
|
||||
)
|
||||
in_scope = df[df["annual_cost"] > 0]["site"].tolist()
|
||||
rows.append(
|
||||
{
|
||||
"cost_line": scope.feature,
|
||||
"scope": ", ".join(in_scope) if in_scope else "—",
|
||||
"annual_cost": float(df["annual_cost"].sum()),
|
||||
"confidence": meter.confidence.value,
|
||||
}
|
||||
)
|
||||
|
||||
return pd.DataFrame(rows)
|
||||
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
CTM default inputs and the Genesys meter catalogue.
|
||||
|
||||
⚠️ Site volumes/AHTs/costs outside NAM are PLACEHOLDERS flagged
|
||||
ESTIMATED — confirm with CTM data before client use. NAM volumes are
|
||||
from the CTM discovery pack. Named users across all sites total the
|
||||
contracted licence count (2,088).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .inputs import CostTakeout, FeatureScope, SiteInput
|
||||
from .meters import Confidence, MeterType, TokenMeter, TokenPricing
|
||||
from .rollout import RolloutPlan
|
||||
|
||||
# ── Platform ─────────────────────────────────────────────────────────
|
||||
|
||||
#: Genesys Cloud CX 3 named-user list rate, USD/user/month.
|
||||
#: Source: Genesys Cloud public pricing (CX 3 tier), planning figure.
|
||||
PLATFORM_RATE_PER_USER_MONTHLY = 111.28
|
||||
|
||||
#: CTM contracted named-user count — UI warns when site totals diverge.
|
||||
CONTRACTED_NAMED_USERS = 2_088
|
||||
|
||||
#: Business-case discount rate (CTM treasury planning assumption).
|
||||
DEFAULT_DISCOUNT_RATE = 0.08
|
||||
|
||||
#: One-off implementation estimate, amortized straight-line over the
|
||||
#: analysis horizon in the P&L. ESTIMATED — confirm with delivery team.
|
||||
DEFAULT_IMPLEMENTATION_COST = 0.0
|
||||
|
||||
_GENESYS_TOKEN_FAQ = (
|
||||
"https://help.mypurecloud.com/articles/genesys-cloud-ai-experience-tokens-faqs/"
|
||||
)
|
||||
|
||||
# ── Token meters ─────────────────────────────────────────────────────
|
||||
# Rates per the published Genesys AI Experience token tables unless
|
||||
# flagged otherwise. UNKNOWN meters carry working defaults (clearly
|
||||
# labelled) so the model still produces a range.
|
||||
|
||||
DEFAULT_METERS: dict[str, TokenMeter] = {
|
||||
m.feature: m
|
||||
for m in [
|
||||
TokenMeter(
|
||||
feature="Voice Bot",
|
||||
meter_type=MeterType.PER_MINUTE,
|
||||
units_per_token=17.0,
|
||||
tokens_per_unit=1 / 17, # 0.0588
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="IVR self-service voice bot minutes; 17 min per token.",
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Virtual Agent (legacy)",
|
||||
meter_type=MeterType.PER_INTERACTION,
|
||||
units_per_token=2.0,
|
||||
tokens_per_unit=0.5,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="Legacy (non-agentic) virtual agent; 2 interactions per token.",
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Agentic Virtual Agent",
|
||||
meter_type=MeterType.PER_INTERACTION,
|
||||
units_per_token=0.833,
|
||||
tokens_per_unit=1.2,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="Agentic VA; 1.2 tokens per interaction.",
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="AI Summary & Insights",
|
||||
meter_type=MeterType.PER_SUMMARY,
|
||||
units_per_token=50.0,
|
||||
tokens_per_unit=0.02,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes=(
|
||||
"Supervisor standalone summarization; 50 summaries per token. "
|
||||
"NOT metered where Agent Copilot is assigned — see cost model."
|
||||
),
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Direct Messaging",
|
||||
meter_type=MeterType.PER_MESSAGE,
|
||||
units_per_token=400.0,
|
||||
tokens_per_unit=0.0025,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="FB/IG/WhatsApp messages; 400 messages per token.",
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Social Listening",
|
||||
meter_type=MeterType.PER_MESSAGE,
|
||||
units_per_token=400.0,
|
||||
tokens_per_unit=0.0025,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="400 messages per token.",
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Social Responses",
|
||||
meter_type=MeterType.PER_MESSAGE,
|
||||
units_per_token=400.0,
|
||||
tokens_per_unit=0.0025,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="400 messages per token.",
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Speech & Text Analytics",
|
||||
meter_type=MeterType.PER_USER_PER_MONTH,
|
||||
units_per_token=0.0, # n/a for per-user meters
|
||||
tokens_per_unit=30.0,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes="STA: 30 tokens per named user per month.",
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Agent Copilot",
|
||||
meter_type=MeterType.PER_USER_PER_MONTH,
|
||||
units_per_token=0.0,
|
||||
tokens_per_unit=40.0,
|
||||
confidence=Confidence.CONFIRMED,
|
||||
notes=(
|
||||
"40 tokens per named user per month. Includes interaction "
|
||||
"summarization (covers AI Summary & Insights)."
|
||||
),
|
||||
source_url=_GENESYS_TOKEN_FAQ,
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Email AI (Auto-Suggest)",
|
||||
meter_type=MeterType.PER_USER_PER_MONTH,
|
||||
units_per_token=0.0,
|
||||
tokens_per_unit=30.0, # TBD — working default
|
||||
confidence=Confidence.UNKNOWN,
|
||||
notes="Rate not yet sourced. Working default 30 tokens/user/month.",
|
||||
),
|
||||
TokenMeter(
|
||||
feature="Email AI (Auto-Respond)",
|
||||
meter_type=MeterType.PER_MESSAGE,
|
||||
units_per_token=2.0, # TBD
|
||||
tokens_per_unit=0.5, # TBD — working default
|
||||
confidence=Confidence.UNKNOWN,
|
||||
notes="Rate not yet sourced. Working default 0.5 tokens/message.",
|
||||
),
|
||||
TokenMeter(
|
||||
feature="AI Translate",
|
||||
meter_type=MeterType.PER_USER_PER_MONTH,
|
||||
units_per_token=0.0,
|
||||
tokens_per_unit=20.0, # TBD — working default
|
||||
confidence=Confidence.UNKNOWN,
|
||||
notes="Rate not yet sourced. Working default 20 tokens/user/month.",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#: Features metered per named user per month.
|
||||
PER_USER_FEATURES = [
|
||||
f for f, m in DEFAULT_METERS.items()
|
||||
if m.meter_type is MeterType.PER_USER_PER_MONTH
|
||||
]
|
||||
|
||||
# ── Token pricing ────────────────────────────────────────────────────
|
||||
# $1/token US list confirmed; other regions default to the same list
|
||||
# rate until regional figures are sourced (override in UI).
|
||||
|
||||
DEFAULT_PRICING: dict[str, TokenPricing] = {
|
||||
"US": TokenPricing(region="US", list_rate_per_token=1.0),
|
||||
"EU": TokenPricing(region="EU", list_rate_per_token=1.0), # TBD — assumed US list
|
||||
"AU": TokenPricing(region="AU", list_rate_per_token=1.0), # TBD — assumed US list
|
||||
"APAC": TokenPricing(region="APAC", list_rate_per_token=1.0), # TBD
|
||||
}
|
||||
|
||||
# ── CTM sites ────────────────────────────────────────────────────────
|
||||
# NAM figures from CTM discovery. ALL OTHER SITES + every AHT/ACW and
|
||||
# labour-cost figure are ESTIMATED placeholders — confirm with CTM.
|
||||
# Named users sum to CONTRACTED_NAMED_USERS (2,088).
|
||||
|
||||
_COMMON = {
|
||||
"voice_aht_seconds": 300, # placeholder — flag as estimate
|
||||
"email_aht_seconds": 600,
|
||||
"chat_aht_seconds": 480,
|
||||
"voice_acw_seconds": 60,
|
||||
}
|
||||
|
||||
CTM_DEFAULT_SITES: list[SiteInput] = [
|
||||
SiteInput(
|
||||
"NAM", "US", agents=890, supervisors=60, # split TBD
|
||||
voice_volume_monthly=1_214_358,
|
||||
email_volume_monthly=275_800,
|
||||
chat_volume_monthly=110,
|
||||
sms_volume_monthly=1_040,
|
||||
fully_loaded_agent_cost_annual=65_000, # placeholder
|
||||
fully_loaded_supervisor_cost_annual=95_000,
|
||||
languages=["English", "French", "Spanish"],
|
||||
**_COMMON,
|
||||
),
|
||||
SiteInput(
|
||||
"EMEA", "EU", agents=320, supervisors=25,
|
||||
voice_volume_monthly=420_000,
|
||||
email_volume_monthly=95_000,
|
||||
chat_volume_monthly=40,
|
||||
sms_volume_monthly=400,
|
||||
fully_loaded_agent_cost_annual=60_000,
|
||||
fully_loaded_supervisor_cost_annual=88_000,
|
||||
languages=["English", "French", "German", "Italian", "Spanish"],
|
||||
**_COMMON,
|
||||
),
|
||||
SiteInput(
|
||||
"AUZ", "AU", agents=180, supervisors=15,
|
||||
voice_volume_monthly=250_000,
|
||||
email_volume_monthly=56_000,
|
||||
chat_volume_monthly=25,
|
||||
sms_volume_monthly=250,
|
||||
fully_loaded_agent_cost_annual=70_000,
|
||||
fully_loaded_supervisor_cost_annual=100_000,
|
||||
languages=["English"],
|
||||
**_COMMON,
|
||||
),
|
||||
SiteInput(
|
||||
"APAC HK", "APAC", agents=120, supervisors=10,
|
||||
voice_volume_monthly=160_000,
|
||||
email_volume_monthly=38_000,
|
||||
chat_volume_monthly=15,
|
||||
sms_volume_monthly=150,
|
||||
fully_loaded_agent_cost_annual=55_000,
|
||||
fully_loaded_supervisor_cost_annual=80_000,
|
||||
languages=["English", "Cantonese", "Mandarin"],
|
||||
**_COMMON,
|
||||
),
|
||||
SiteInput(
|
||||
"APAC SG", "APAC", agents=110, supervisors=10,
|
||||
voice_volume_monthly=150_000,
|
||||
email_volume_monthly=34_000,
|
||||
chat_volume_monthly=15,
|
||||
sms_volume_monthly=120,
|
||||
fully_loaded_agent_cost_annual=55_000,
|
||||
fully_loaded_supervisor_cost_annual=80_000,
|
||||
languages=["English", "Mandarin", "Malay"],
|
||||
**_COMMON,
|
||||
),
|
||||
SiteInput(
|
||||
"APAC SH", "APAC", agents=130, supervisors=10,
|
||||
voice_volume_monthly=175_000,
|
||||
email_volume_monthly=40_000,
|
||||
chat_volume_monthly=15,
|
||||
sms_volume_monthly=130,
|
||||
fully_loaded_agent_cost_annual=35_000,
|
||||
fully_loaded_supervisor_cost_annual=55_000,
|
||||
languages=["Mandarin"],
|
||||
**_COMMON,
|
||||
),
|
||||
SiteInput(
|
||||
"APAC GZ", "APAC", agents=90, supervisors=8,
|
||||
voice_volume_monthly=120_000,
|
||||
email_volume_monthly=28_000,
|
||||
chat_volume_monthly=10,
|
||||
sms_volume_monthly=100,
|
||||
fully_loaded_agent_cost_annual=35_000,
|
||||
fully_loaded_supervisor_cost_annual=55_000,
|
||||
languages=["Mandarin", "Cantonese"],
|
||||
**_COMMON,
|
||||
),
|
||||
SiteInput(
|
||||
"APAC JP", "APAC", agents=60, supervisors=6,
|
||||
voice_volume_monthly=80_000,
|
||||
email_volume_monthly=19_000,
|
||||
chat_volume_monthly=8,
|
||||
sms_volume_monthly=80,
|
||||
fully_loaded_agent_cost_annual=60_000,
|
||||
fully_loaded_supervisor_cost_annual=85_000,
|
||||
languages=["Japanese"],
|
||||
**_COMMON,
|
||||
),
|
||||
SiteInput(
|
||||
"APAC TW", "APAC", agents=40, supervisors=4,
|
||||
voice_volume_monthly=54_000,
|
||||
email_volume_monthly=12_000,
|
||||
chat_volume_monthly=5,
|
||||
sms_volume_monthly=50,
|
||||
fully_loaded_agent_cost_annual=40_000,
|
||||
fully_loaded_supervisor_cost_annual=60_000,
|
||||
languages=["Mandarin"],
|
||||
**_COMMON,
|
||||
),
|
||||
]
|
||||
|
||||
ALL_SITE_NAMES = [s.site_name for s in CTM_DEFAULT_SITES]
|
||||
|
||||
# ── Cost takeouts ────────────────────────────────────────────────────
|
||||
|
||||
CTM_DEFAULT_TAKEOUTS: list[CostTakeout] = [
|
||||
CostTakeout(
|
||||
"NICE IEX (NAM)",
|
||||
annual_cost=1_300_000,
|
||||
start_year=1,
|
||||
start_month=7, # can only switch off after NAM go-live (month 6)
|
||||
confidence=Confidence.ESTIMATED,
|
||||
notes="Mid-band estimate; needs CTM contract confirmation.",
|
||||
),
|
||||
CostTakeout(
|
||||
"Legacy CC platform",
|
||||
annual_cost=0,
|
||||
start_year=2,
|
||||
confidence=Confidence.UNKNOWN,
|
||||
notes="Placeholder — populate once retirement scope is confirmed.",
|
||||
),
|
||||
]
|
||||
|
||||
# ── Default rollout & ramp ───────────────────────────────────────────
|
||||
# 12-month build. Genesys bills the licence commit from contract start;
|
||||
# the 6-month ramp gives a 50% first-year credit on the platform commit.
|
||||
# AI token usage (and benefits) start only when each region goes live.
|
||||
|
||||
CTM_DEFAULT_ROLLOUT = RolloutPlan(
|
||||
contract_start=None, # set when known — "Date Genesys starts billing"
|
||||
build_months=12,
|
||||
ramp_months=6,
|
||||
first_year_platform_discount=0.50,
|
||||
go_live_month={
|
||||
"NAM": 6,
|
||||
"EMEA": 9,
|
||||
"AUZ": 12,
|
||||
"APAC HK": 12,
|
||||
"APAC SG": 12,
|
||||
"APAC SH": 12,
|
||||
"APAC GZ": 12,
|
||||
"APAC JP": 12,
|
||||
"APAC TW": 12,
|
||||
},
|
||||
)
|
||||
|
||||
# ── Default feature scoping / phasing ────────────────────────────────
|
||||
# Phase = model year the feature switches on. Consumption features ramp
|
||||
# via adoption_curve; per-user licences are paid in full from the phase
|
||||
# year.
|
||||
|
||||
_RAMP = {1: 0.70, 2: 1.0, 3: 1.0}
|
||||
|
||||
CTM_DEFAULT_FEATURE_SCOPES: list[FeatureScope] = [
|
||||
FeatureScope("Voice Bot", ALL_SITE_NAMES, phase=1, adoption_curve=_RAMP),
|
||||
FeatureScope("Agentic Virtual Agent", ["NAM", "EMEA"], phase=2,
|
||||
adoption_curve={2: 0.70, 3: 1.0}),
|
||||
FeatureScope("Speech & Text Analytics", ALL_SITE_NAMES, phase=1),
|
||||
FeatureScope("Agent Copilot", ALL_SITE_NAMES, phase=1),
|
||||
FeatureScope("AI Summary & Insights", ALL_SITE_NAMES, phase=1,
|
||||
adoption_curve=_RAMP),
|
||||
FeatureScope("Direct Messaging", ALL_SITE_NAMES, phase=1, adoption_curve=_RAMP),
|
||||
FeatureScope("Email AI (Auto-Suggest)", ["NAM", "EMEA"], phase=2),
|
||||
FeatureScope("Email AI (Auto-Respond)", ["NAM", "EMEA"], phase=2,
|
||||
adoption_curve={2: 0.70, 3: 1.0}),
|
||||
FeatureScope("AI Translate",
|
||||
["APAC HK", "APAC SG", "APAC SH", "APAC GZ", "APAC JP", "APAC TW"],
|
||||
phase=3),
|
||||
]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Excel / CSV / JSON export.
|
||||
|
||||
Excel uses openpyxl via pandas — multi-sheet workbooks readable in
|
||||
Excel 2019+. JSON round-trips the full input state (sites, takeouts,
|
||||
feature scopes) so a scenario can be saved and reloaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .inputs import CostTakeout, FeatureScope, SiteInput
|
||||
from .meters import Confidence, TokenMeter
|
||||
from .rollout import RolloutPlan
|
||||
|
||||
|
||||
def meters_dataframe(meters: dict[str, TokenMeter]) -> pd.DataFrame:
|
||||
"""Meter catalogue as a display/export-ready DataFrame."""
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"feature": m.feature,
|
||||
"meter_type": m.meter_type.value,
|
||||
"units_per_token": m.units_per_token or None,
|
||||
"tokens_per_unit": m.tokens_per_unit,
|
||||
"confidence": f"{m.confidence.icon} {m.confidence.value}",
|
||||
"notes": m.notes,
|
||||
"source": m.source_url or "",
|
||||
}
|
||||
for m in meters.values()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def sites_dataframe(sites: list[SiteInput]) -> pd.DataFrame:
|
||||
rows = []
|
||||
for s in sites:
|
||||
d = dataclasses.asdict(s)
|
||||
d["languages"] = ", ".join(d["languages"])
|
||||
rows.append(d)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def export_excel(
|
||||
sheets: dict[str, pd.DataFrame],
|
||||
path: str | Path,
|
||||
) -> Path:
|
||||
"""Write a multi-sheet Excel workbook. Sheet names are truncated to
|
||||
Excel's 31-character limit."""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with pd.ExcelWriter(path, engine="openpyxl") as writer:
|
||||
for name, df in sheets.items():
|
||||
df.to_excel(writer, sheet_name=name[:31], index=False)
|
||||
return path
|
||||
|
||||
|
||||
def export_csv(df: pd.DataFrame, path: str | Path) -> Path:
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df.to_csv(path, index=False)
|
||||
return path
|
||||
|
||||
|
||||
# ── JSON scenario save / load ────────────────────────────────────────
|
||||
|
||||
def scenario_state_to_json(
|
||||
sites: list[SiteInput],
|
||||
takeouts: list[CostTakeout],
|
||||
feature_scopes: list[FeatureScope],
|
||||
path: str | Path | None = None,
|
||||
rollout: RolloutPlan | None = None,
|
||||
) -> str:
|
||||
"""Serialize the full input state; optionally write to ``path``."""
|
||||
state = {
|
||||
"sites": [dataclasses.asdict(s) for s in sites],
|
||||
"takeouts": [
|
||||
{**dataclasses.asdict(t), "confidence": t.confidence.value}
|
||||
for t in takeouts
|
||||
],
|
||||
"feature_scopes": [
|
||||
{
|
||||
**dataclasses.asdict(f),
|
||||
"adoption_curve": {str(k): v for k, v in f.adoption_curve.items()},
|
||||
}
|
||||
for f in feature_scopes
|
||||
],
|
||||
}
|
||||
if rollout is not None:
|
||||
state["rollout"] = dataclasses.asdict(rollout)
|
||||
text = json.dumps(state, indent=2)
|
||||
if path is not None:
|
||||
Path(path).write_text(text)
|
||||
return text
|
||||
|
||||
|
||||
def scenario_state_from_json(
|
||||
source: str | Path,
|
||||
) -> tuple[list[SiteInput], list[CostTakeout], list[FeatureScope], RolloutPlan | None]:
|
||||
"""Inverse of :func:`scenario_state_to_json`. ``source`` is a JSON
|
||||
string or a file path. The fourth element is None for legacy files
|
||||
saved without a rollout plan."""
|
||||
raw = (
|
||||
Path(source).read_text()
|
||||
if isinstance(source, Path) or (isinstance(source, str) and source.strip().endswith(".json"))
|
||||
else str(source)
|
||||
)
|
||||
state = json.loads(raw)
|
||||
sites = [SiteInput(**s) for s in state["sites"]]
|
||||
takeouts = [
|
||||
CostTakeout(**{**t, "confidence": Confidence(t["confidence"])})
|
||||
for t in state["takeouts"]
|
||||
]
|
||||
scopes = [
|
||||
FeatureScope(
|
||||
**{
|
||||
**f,
|
||||
"adoption_curve": {int(k): v for k, v in f["adoption_curve"].items()},
|
||||
}
|
||||
)
|
||||
for f in state["feature_scopes"]
|
||||
]
|
||||
rollout = (
|
||||
RolloutPlan(**state["rollout"]) if "rollout" in state else None
|
||||
)
|
||||
return sites, takeouts, scopes, rollout
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Input bundles — validated dataclasses, no untyped dicts.
|
||||
|
||||
All volumes are MONTHLY; all AHT/ACW figures are SECONDS; all labour
|
||||
costs are ANNUAL fully-loaded USD.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .meters import Confidence
|
||||
|
||||
#: Sanity bounds for handle times (seconds).
|
||||
AHT_MIN_SECONDS = 10
|
||||
AHT_MAX_SECONDS = 3600
|
||||
|
||||
#: Working hours per FTE-year used to derive per-second labour rates.
|
||||
WORKING_HOURS_PER_YEAR = 2_080
|
||||
WORKING_SECONDS_PER_YEAR = WORKING_HOURS_PER_YEAR * 3600
|
||||
|
||||
|
||||
@dataclass
|
||||
class SiteInput:
|
||||
site_name: str # "NAM", "EMEA", "AUZ", "APAC HK", …
|
||||
region_pricing: str # "US", "AU", "EU", "APAC"
|
||||
agents: int # excluding supervisors
|
||||
supervisors: int
|
||||
voice_volume_monthly: int
|
||||
email_volume_monthly: int
|
||||
chat_volume_monthly: int
|
||||
sms_volume_monthly: int
|
||||
voice_aht_seconds: int
|
||||
email_aht_seconds: int
|
||||
chat_aht_seconds: int
|
||||
voice_acw_seconds: int
|
||||
fully_loaded_agent_cost_annual: float
|
||||
fully_loaded_supervisor_cost_annual: float
|
||||
languages: list[str] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.agents < 0 or self.supervisors < 0:
|
||||
raise ValueError(f"{self.site_name}: agent/supervisor counts must be >= 0")
|
||||
for name in (
|
||||
"voice_volume_monthly",
|
||||
"email_volume_monthly",
|
||||
"chat_volume_monthly",
|
||||
"sms_volume_monthly",
|
||||
):
|
||||
if getattr(self, name) < 0:
|
||||
raise ValueError(f"{self.site_name}: {name} must be >= 0")
|
||||
for name in ("voice_aht_seconds", "email_aht_seconds", "chat_aht_seconds"):
|
||||
v = getattr(self, name)
|
||||
if v and not AHT_MIN_SECONDS <= v <= AHT_MAX_SECONDS:
|
||||
raise ValueError(
|
||||
f"{self.site_name}: {name}={v}s outside sensible bounds "
|
||||
f"({AHT_MIN_SECONDS}-{AHT_MAX_SECONDS}s)"
|
||||
)
|
||||
if self.voice_acw_seconds < 0:
|
||||
raise ValueError(f"{self.site_name}: voice_acw_seconds must be >= 0")
|
||||
|
||||
@property
|
||||
def named_users(self) -> int:
|
||||
return self.agents + self.supervisors
|
||||
|
||||
@property
|
||||
def agent_cost_per_second(self) -> float:
|
||||
"""Fully-loaded agent labour rate per working second (DBZ-safe)."""
|
||||
return self.fully_loaded_agent_cost_annual / WORKING_SECONDS_PER_YEAR
|
||||
|
||||
@property
|
||||
def supervisor_cost_per_second(self) -> float:
|
||||
return self.fully_loaded_supervisor_cost_annual / WORKING_SECONDS_PER_YEAR
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureScope:
|
||||
"""Which feature is enabled at which sites, in which phase.
|
||||
|
||||
``phase`` is the model year (1-3) the feature switches on;
|
||||
``adoption_curve`` maps model year -> adoption fraction (0.0-1.0)
|
||||
applied to consumption-metered features (per-user licenses are paid
|
||||
in full from the phase year onward).
|
||||
"""
|
||||
|
||||
feature: str
|
||||
enabled_sites: list[str]
|
||||
phase: int = 1
|
||||
adoption_curve: dict[int, float] = field(default_factory=dict)
|
||||
deflection_target: float | None = None
|
||||
eligibility_pct: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.phase < 1:
|
||||
raise ValueError(f"{self.feature}: phase must be >= 1")
|
||||
for year, pct in self.adoption_curve.items():
|
||||
if not 0.0 <= pct <= 1.0:
|
||||
raise ValueError(
|
||||
f"{self.feature}: adoption_curve[{year}]={pct} outside 0-1"
|
||||
)
|
||||
for name in ("deflection_target", "eligibility_pct"):
|
||||
v = getattr(self, name)
|
||||
if v is not None and not 0.0 <= v <= 1.0:
|
||||
raise ValueError(f"{self.feature}: {name}={v} outside 0-1")
|
||||
|
||||
def active(self, site_name: str, year: int) -> bool:
|
||||
return site_name in self.enabled_sites and year >= self.phase
|
||||
|
||||
def adoption(self, year: int) -> float:
|
||||
"""Adoption fraction for ``year`` (1.0 when no curve given)."""
|
||||
if not self.adoption_curve:
|
||||
return 1.0
|
||||
if year in self.adoption_curve:
|
||||
return self.adoption_curve[year]
|
||||
# Past the last defined year → hold the last value.
|
||||
last = max(self.adoption_curve)
|
||||
return self.adoption_curve[last] if year > last else 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostTakeout:
|
||||
"""A retired platform/licence whose cost the programme reclaims.
|
||||
|
||||
``start_month`` (1-12, within ``start_year``) prorates the first
|
||||
active year — e.g. NICE IEX can only be switched off once NAM is
|
||||
live, so start_year=1, start_month=7 reclaims 6/12 of Y1.
|
||||
"""
|
||||
|
||||
name: str # "NICE IEX (NAM)", "Legacy CC platform", …
|
||||
annual_cost: float
|
||||
start_year: int = 1
|
||||
confidence: Confidence = Confidence.ESTIMATED
|
||||
notes: str = ""
|
||||
start_month: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.annual_cost < 0:
|
||||
raise ValueError(f"{self.name}: annual_cost must be >= 0")
|
||||
if self.start_year < 1:
|
||||
raise ValueError(f"{self.name}: start_year must be >= 1")
|
||||
if not 1 <= self.start_month <= 12:
|
||||
raise ValueError(f"{self.name}: start_month must be 1-12")
|
||||
|
||||
def value_in_year(self, year: int) -> float:
|
||||
if year < self.start_year:
|
||||
return 0.0
|
||||
if year == self.start_year:
|
||||
return self.annual_cost * (12 - (self.start_month - 1)) / 12
|
||||
return self.annual_cost
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Genesys AI Experience token meters and pricing.
|
||||
|
||||
Every meter carries a :class:`Confidence` flag so the UI can distinguish
|
||||
published Genesys rates from estimates and unknowns. Rates here are
|
||||
*planning inputs* — this tool explicitly does not replace contractual
|
||||
pricing (see README, Non-Goals).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class MeterType(Enum):
|
||||
PER_USER_PER_MONTH = "per_user_per_month"
|
||||
PER_INTERACTION = "per_interaction"
|
||||
PER_MINUTE = "per_minute"
|
||||
PER_MESSAGE = "per_message"
|
||||
PER_SUMMARY = "per_summary"
|
||||
|
||||
|
||||
class Confidence(Enum):
|
||||
CONFIRMED = "confirmed" # published Genesys rate
|
||||
ESTIMATED = "estimated" # reasonable industry assumption
|
||||
UNKNOWN = "unknown" # rate not yet sourced
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return {"confirmed": "🟢", "estimated": "🟡", "unknown": "🔴"}[self.value]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenMeter:
|
||||
"""One Genesys AI feature's token meter.
|
||||
|
||||
``units_per_token`` and ``tokens_per_unit`` are inverses; both are
|
||||
stored because the UI shows whichever reads more naturally (e.g.
|
||||
"17 minutes per token" vs "0.0588 tokens per minute"). For
|
||||
PER_USER_PER_MONTH meters ``units_per_token`` is 0.0 (n/a) and
|
||||
``tokens_per_unit`` is the flat tokens/user/month figure.
|
||||
"""
|
||||
|
||||
feature: str
|
||||
meter_type: MeterType
|
||||
units_per_token: float
|
||||
tokens_per_unit: float
|
||||
confidence: Confidence
|
||||
notes: str
|
||||
source_url: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.tokens_per_unit < 0:
|
||||
raise ValueError(f"{self.feature}: tokens_per_unit must be >= 0")
|
||||
if (
|
||||
self.meter_type is not MeterType.PER_USER_PER_MONTH
|
||||
and self.units_per_token > 0
|
||||
and self.tokens_per_unit > 0
|
||||
):
|
||||
product = self.units_per_token * self.tokens_per_unit
|
||||
if not 0.95 <= product <= 1.05:
|
||||
raise ValueError(
|
||||
f"{self.feature}: units_per_token ({self.units_per_token}) and "
|
||||
f"tokens_per_unit ({self.tokens_per_unit}) are not inverses"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenPricing:
|
||||
"""Per-region token pricing. Default is US list at $1/token."""
|
||||
|
||||
region: str # "US", "AU", "EU", "APAC"
|
||||
list_rate_per_token: float = 1.0
|
||||
contracted_rate_per_token: float | None = None
|
||||
prepay_commit_tokens: int | None = None
|
||||
overage_rate_per_token: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.list_rate_per_token < 0:
|
||||
raise ValueError(f"{self.region}: list rate must be >= 0")
|
||||
|
||||
def effective_rate(self, use_contracted: bool = False) -> float:
|
||||
"""Contracted rate when requested and known, else list rate."""
|
||||
if use_contracted and self.contracted_rate_per_token is not None:
|
||||
return self.contracted_rate_per_token
|
||||
return self.list_rate_per_token
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Implementation rollout & ramp model.
|
||||
|
||||
Captures the gap between **when Genesys starts billing** (contract
|
||||
start) and **when each region actually goes live**:
|
||||
|
||||
- The platform licence commit bills in full from contract start; the
|
||||
vendor's *ramp period* compensates with a first-year credit
|
||||
(typical: 6-month ramp → 50% Y1 discount on the platform commit).
|
||||
- AI token usage (per-user and consumption meters) starts only when a
|
||||
site goes live, and bills for the months the site is live in each
|
||||
model year.
|
||||
- Benefits likewise accrue only from go-live (the scenario realization
|
||||
curve then models adoption maturity *within* the live period).
|
||||
|
||||
A site with ``go_live_month = m`` is live for ``12*year − m`` months of
|
||||
the first ``year`` years (clamped to 0..12 per year). So NAM at month 6
|
||||
is live 6 months of Y1; EMEA at month 9 → 3 months; AUZ/APAC at month
|
||||
12 → 0 months in Y1 and fully live from Y2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
MONTHS_PER_YEAR = 12
|
||||
|
||||
|
||||
@dataclass
|
||||
class RolloutPlan:
|
||||
#: ISO date Genesys starts billing the licence commit (informational,
|
||||
#: surfaced in UI/exports; the model works in months-from-start).
|
||||
contract_start: str | None = None
|
||||
|
||||
#: Total build duration, months (informational).
|
||||
build_months: int = 12
|
||||
|
||||
#: Vendor ramp period, months. Documentation for the Y1 credit below.
|
||||
ramp_months: int = 6
|
||||
|
||||
#: First-year credit on the platform licence commit. Typical
|
||||
#: 6-month ramp = 50% discount in year 1; years 2+ bill in full.
|
||||
first_year_platform_discount: float = 0.5
|
||||
|
||||
#: site_name -> go-live month (months after contract start).
|
||||
#: Sites absent from the map are treated as live from day 0.
|
||||
go_live_month: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 0.0 <= self.first_year_platform_discount <= 1.0:
|
||||
raise ValueError("first_year_platform_discount must be within 0-1")
|
||||
if self.ramp_months < 0 or self.build_months < 0:
|
||||
raise ValueError("ramp_months/build_months must be >= 0")
|
||||
for site, m in self.go_live_month.items():
|
||||
if m < 0:
|
||||
raise ValueError(f"{site}: go_live_month must be >= 0")
|
||||
|
||||
# ── Availability ────────────────────────────────────────────────
|
||||
|
||||
def live_months_in_year(self, site_name: str, year: int) -> int:
|
||||
"""Months ``site_name`` is live during model year ``year`` (1-based)."""
|
||||
go_live = self.go_live_month.get(site_name, 0)
|
||||
live_by_year_end = max(0, MONTHS_PER_YEAR * year - go_live)
|
||||
live_by_prev_year_end = max(0, MONTHS_PER_YEAR * (year - 1) - go_live)
|
||||
return min(MONTHS_PER_YEAR, live_by_year_end - live_by_prev_year_end)
|
||||
|
||||
def fraction_live(self, site_name: str, year: int) -> float:
|
||||
return self.live_months_in_year(site_name, year) / MONTHS_PER_YEAR
|
||||
|
||||
# ── Billing ─────────────────────────────────────────────────────
|
||||
|
||||
def platform_factor(self, year: int) -> float:
|
||||
"""Fraction of the full platform commit billed in ``year``."""
|
||||
return 1.0 - self.first_year_platform_discount if year == 1 else 1.0
|
||||
|
||||
|
||||
#: Behaviour identical to the pre-rollout model: everything live from
|
||||
#: day 0, no ramp credit.
|
||||
NO_ROLLOUT = RolloutPlan(
|
||||
build_months=0, ramp_months=0, first_year_platform_discount=0.0
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Scenario definitions — Floor / Realistic / Stretch.
|
||||
|
||||
Every scenario parameter the cost and benefit engines read lives here;
|
||||
no magic numbers in the calculation modules. Ships with the spec
|
||||
defaults; callers may construct custom :class:`Scenario` objects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scenario:
|
||||
name: str
|
||||
|
||||
# ── Cost-side drivers ───────────────────────────────────────────
|
||||
voice_bot_deflection: float # share of voice volume deflected to bot
|
||||
voice_bot_avg_minutes: float # bot minutes per deflected call
|
||||
agentic_va_deflection: float # share of voice volume to agentic VA
|
||||
voice_summarization_eligibility: float
|
||||
voice_knowledge_eligibility: float
|
||||
email_auto_respond_rate: float # share of email auto-responded
|
||||
email_auto_suggest_acceptance: float
|
||||
|
||||
# year -> fraction of full benefit realized
|
||||
benefit_realization: dict[int, float] = field(default_factory=dict)
|
||||
|
||||
# year -> fraction of steady-state consumption cost incurred.
|
||||
# Per-user licenses are paid in full from day 1; consumption meters
|
||||
# ramp with usage (default Y1 = 70%).
|
||||
consumption_cost_realization: dict[int, float] = field(
|
||||
default_factory=lambda: {1: 0.70, 2: 1.0, 3: 1.0}
|
||||
)
|
||||
|
||||
def realization(self, year: int) -> float:
|
||||
if year in self.benefit_realization:
|
||||
return self.benefit_realization[year]
|
||||
last = max(self.benefit_realization, default=0)
|
||||
return self.benefit_realization.get(last, 1.0) if year > last else 0.0
|
||||
|
||||
def cost_realization(self, year: int) -> float:
|
||||
if year in self.consumption_cost_realization:
|
||||
return self.consumption_cost_realization[year]
|
||||
last = max(self.consumption_cost_realization, default=0)
|
||||
return (
|
||||
self.consumption_cost_realization.get(last, 1.0) if year > last else 0.0
|
||||
)
|
||||
|
||||
|
||||
#: Benefit reduction parameters. ``claim`` = Genesys ROI-doc figure;
|
||||
#: ``realistic`` = pressure-tested midpoint of the spec's Y1 range.
|
||||
#: The benefit engine uses ``realistic`` by default; ``claim`` powers
|
||||
#: the side-by-side comparison view.
|
||||
BENEFIT_PARAMS: dict[str, dict[str, float]] = {
|
||||
"voice_aht_knowledge_reduction": {"claim": 0.094, "realistic": 0.055}, # 4-7% Y1
|
||||
"voice_acw_reduction": {"claim": 1.00, "realistic": 0.40}, # 30-50% Y1
|
||||
"digital_aht_reduction": {"claim": 0.18, "realistic": 0.085}, # 5-12% Y1
|
||||
"digital_acw_reduction": {"claim": 1.00, "realistic": 0.40}, # 30-50% Y1
|
||||
"sta_aht_reduction": {"claim": 0.04, "realistic": 0.015}, # 1-2% Y1
|
||||
"email_auto_suggest_time_saving": {"claim": 0.30, "realistic": 0.30}, # × acceptance
|
||||
# ESTIMATED lines (no Genesys claim published):
|
||||
"supervisor_copilot_time_saving": {"claim": 0.10, "realistic": 0.05},
|
||||
"predictive_routing_aht_reduction": {"claim": 0.04, "realistic": 0.02},
|
||||
}
|
||||
|
||||
|
||||
SCENARIOS: dict[str, Scenario] = {
|
||||
"floor": Scenario(
|
||||
name="floor",
|
||||
voice_bot_deflection=0.20,
|
||||
voice_bot_avg_minutes=1.0,
|
||||
agentic_va_deflection=0.05,
|
||||
voice_summarization_eligibility=0.50,
|
||||
voice_knowledge_eligibility=0.40,
|
||||
email_auto_respond_rate=0.10,
|
||||
email_auto_suggest_acceptance=0.25,
|
||||
benefit_realization={1: 0.30, 2: 0.60, 3: 0.80},
|
||||
),
|
||||
"realistic": Scenario(
|
||||
name="realistic",
|
||||
voice_bot_deflection=0.35,
|
||||
voice_bot_avg_minutes=1.5,
|
||||
agentic_va_deflection=0.15,
|
||||
voice_summarization_eligibility=0.70,
|
||||
voice_knowledge_eligibility=0.60,
|
||||
email_auto_respond_rate=0.20,
|
||||
email_auto_suggest_acceptance=0.40,
|
||||
benefit_realization={1: 0.50, 2: 0.80, 3: 0.95},
|
||||
),
|
||||
"stretch": Scenario(
|
||||
name="stretch",
|
||||
voice_bot_deflection=0.50,
|
||||
voice_bot_avg_minutes=2.0,
|
||||
agentic_va_deflection=0.25,
|
||||
voice_summarization_eligibility=0.90,
|
||||
voice_knowledge_eligibility=0.80,
|
||||
email_auto_respond_rate=0.50,
|
||||
email_auto_suggest_acceptance=0.60,
|
||||
benefit_realization={1: 0.75, 2: 0.95, 3: 1.00},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_scenario(name: str) -> Scenario:
|
||||
try:
|
||||
return SCENARIOS[name.lower()]
|
||||
except KeyError as e:
|
||||
raise KeyError(
|
||||
f"Unknown scenario {name!r}. Valid: {sorted(SCENARIOS)}"
|
||||
) from e
|
||||
Reference in New Issue
Block a user