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:
2026-06-10 14:26:49 -04:00
parent ecd164ee6d
commit 64fb83257d
34 changed files with 12902 additions and 39 deletions

View File

@@ -0,0 +1,4 @@
exports/
__pycache__/
*.pyc
.ipynb_checkpoints/

View 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.

View File

@@ -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 050% 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

View 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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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"}

View File

@@ -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",
]

View File

@@ -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"]]

View File

@@ -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
),
}

View File

@@ -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)

View File

@@ -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),
]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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