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

@@ -264,6 +264,10 @@ palladium/
│ ├── views/ # benefits, costs, summary, versions (NOT `pages/` — avoids Streamlit auto-multipage)
│ └── components/ # tables, charts
├── studies/ # One folder per TEI engagement
│ ├── 202512_GenesysCX/ # CX Cloud (Genesys + Salesforce) TEI
│ │ ├── README.md # NPV $10.8M · ROI 266% + AI-token line
│ │ ├── config.py / seed_data.py # study-scoped PALLADIUM_GENESYSCX_* keys
│ │ └── notebooks/ # 00_provision, 01_business_case
│ └── 202602_AmazonConnect/
│ ├── README.md
│ ├── config.py # TOOL_PUBLIC_ID, REPORT_PUBLIC_ID

View File

@@ -1,4 +1,9 @@
"""Streamlit-friendly chart wrappers (delegate to core.notebook_helpers.charts)."""
"""Streamlit-friendly chart wrappers (delegate to core.notebook_helpers.charts).
Every wrapper takes a ``key`` — the same figure type renders on multiple
tabs (Summary, Benefits, Costs) within one script run, so Streamlit needs
explicit element IDs to avoid StreamlitDuplicateElementId errors.
"""
from __future__ import annotations
@@ -7,26 +12,31 @@ import streamlit as st
from core.notebook_helpers import charts as core_charts
def cashflow(yearly_breakdown, *, initial_cost: float = 0.0) -> None:
def cashflow(yearly_breakdown, *, initial_cost: float = 0.0, key: str = "cashflow") -> None:
fig = core_charts.cashflow_chart(yearly_breakdown, initial_cost=initial_cost)
st.plotly_chart(fig, width="stretch")
st.plotly_chart(fig, width="stretch", key=key)
def benefits_bar(items) -> None:
def benefits_bar(items, *, key: str = "benefits_bar") -> None:
fig = core_charts.benefits_bar(items)
st.plotly_chart(fig, width="stretch")
st.plotly_chart(fig, width="stretch", key=key)
def cost_pie(items) -> None:
def cost_pie(items, *, key: str = "cost_pie") -> None:
fig = core_charts.cost_breakdown_pie(items)
st.plotly_chart(fig, width="stretch")
st.plotly_chart(fig, width="stretch", key=key)
def scenario_bars(scenarios) -> None:
def benefits_vs_costs_by_year(benefit_items, cost_items, *, key: str = "by_year") -> None:
fig = core_charts.benefits_vs_costs_by_year(benefit_items, cost_items)
st.plotly_chart(fig, width="stretch", key=key)
def scenario_bars(scenarios, *, key: str = "scenario_bars") -> None:
fig = core_charts.scenario_comparison(scenarios)
st.plotly_chart(fig, width="stretch")
st.plotly_chart(fig, width="stretch", key=key)
def waterfall(values) -> None:
def waterfall(values, *, key: str = "waterfall") -> None:
fig = core_charts.waterfall(values)
st.plotly_chart(fig, width="stretch")
st.plotly_chart(fig, width="stretch", key=key)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import streamlit as st
from app.components import charts
from app.components.tables import df_to_values, value_editor
from app.utils import icon
@@ -50,3 +51,7 @@ def render(client: TEIClient, tool: dict) -> None:
"applied at calculate time. Use the Recalculate button in the "
"sidebar after saving to refresh the summary."
)
if values:
st.divider()
charts.benefits_bar(values, key=f"benefits_tab_bar_{public_id}")

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import streamlit as st
from app.components import charts
from app.components.tables import df_to_values, value_editor
from app.utils import icon
@@ -50,3 +51,18 @@ def render(client: TEIClient, tool: dict) -> None:
"are end-of-year cashflows. Costs are risk-adjusted upward "
"(higher risk → higher cost)."
)
if values:
st.divider()
col_pie, col_year = st.columns(2)
with col_pie:
charts.cost_pie(values, key=f"costs_tab_pie_{public_id}")
with col_year:
benefit_values = [
v
for v in safe(client.get_values, public_id) or []
if v.get("table") == "benefits"
]
charts.benefits_vs_costs_by_year(
benefit_values, values, key=f"costs_tab_by_year_{public_id}"
)

View File

@@ -56,19 +56,61 @@ def render(client: TEIClient, tool: dict) -> None:
st.divider()
# Build the yearly breakdown from the documented per-year summary keys
# (benefits_year_N / costs_year_N) when no pre-built breakdown exists.
yb = summary.get("yearly_breakdown") or []
# ── Financial visualizations ────────────────────────────────────
# Built from the live value rows so Year-0 "Initial" amounts stay
# separate (Athena's per-year summary folds them into Year 1).
values = safe(client.get_values, public_id) or []
benefit_rows = [v for v in values if v.get("table") == "benefits"]
cost_rows = [v for v in values if v.get("table") == "costs"]
if benefit_rows or cost_rows:
col_pie, col_bar = st.columns(2)
with col_pie:
charts.cost_pie(cost_rows, key=f"summary_pie_{public_id}")
with col_bar:
charts.benefits_bar(benefit_rows, key=f"summary_bar_{public_id}")
charts.benefits_vs_costs_by_year(
benefit_rows, cost_rows, key=f"summary_by_year_{public_id}"
)
# Cash flow + cumulative net — the Forrester-style exhibit.
def _yearly_breakdown_from_values():
initial = sum(float(c.get("initial") or 0) for c in cost_rows)
years: set[int] = set()
for v in [*benefit_rows, *cost_rows]:
years.update(int(y) for y in (v.get("year_values") or {}))
rows, cumulative = [], -initial
for y in sorted(years):
b = sum(
float((v.get("year_values") or {}).get(str(y), 0) or 0)
* (1 - float(v.get("risk_adjustment") or 0))
for v in benefit_rows
)
c = sum(
float((v.get("year_values") or {}).get(str(y), 0) or 0)
for v in cost_rows
)
cumulative += b - c
rows.append(
{"year": y, "benefits": b, "costs": c, "net": b - c,
"cumulative_net": cumulative}
)
return rows, initial
yb, initial = ([], 0.0)
if benefit_rows or cost_rows:
yb, initial = _yearly_breakdown_from_values()
if not yb:
# Fallback: documented per-year summary keys (initial folded in Y1).
n = 1
while f"benefits_year_{n}" in summary or f"costs_year_{n}" in summary:
b = float(summary.get(f"benefits_year_{n}") or 0)
c = float(summary.get(f"costs_year_{n}") or 0)
yb.append({"year": n, "benefits": b, "costs": c, "net": b - c})
n += 1
initial = float(summary.get("initial_costs") or 0)
initial = float(summary.get("initial_costs") or 0)
if yb:
charts.cashflow(yb, initial_cost=initial)
charts.cashflow(yb, initial_cost=initial, key=f"summary_cashflow_{public_id}")
with st.expander("Cash flow table"):
_cur = currency_fmt()
st.dataframe(
@@ -85,6 +127,14 @@ def render(client: TEIClient, tool: dict) -> None:
else:
st.caption("No yearly breakdown in this summary.")
# Waterfall — Benefits PV down to NPV.
if bpv or cpv:
charts.waterfall([
("Benefits PV", bpv),
("Costs PV", -cpv),
("NPV", npv),
], key=f"summary_waterfall_{public_id}")
# Scenario comparison — computed locally from current values
with st.expander("Scenario analysis (conservative / moderate / aggressive)"):
envelope = safe(
@@ -95,7 +145,9 @@ def render(client: TEIClient, tool: dict) -> None:
study_slug=report.get("name", ""),
)
if envelope and envelope.get("scenarios"):
charts.scenario_bars(envelope["scenarios"])
charts.scenario_bars(
envelope["scenarios"], key=f"summary_scenarios_{public_id}"
)
rows = [
{
"Scenario": k,

View File

@@ -20,6 +20,70 @@ PALETTE = {
"cumulative": "#616161", # grey
}
#: Visual theme — override per study/client with :func:`apply_theme`.
#: Hex colours; fonts are CSS font-family strings.
THEME = {
"heading_font": "Helvetica Neue, Arial, sans-serif",
"body_font": "Helvetica, Arial, sans-serif",
"font_color": "#1F2937",
# Circle-chart slice colours aj, used in order.
"pie_colors": [
"#1565C0", # a
"#2E7D32", # b
"#C62828", # c
"#F9A825", # d
"#6A1B9A", # e
"#00838F", # f
"#EF6C00", # g
"#5D4037", # h
"#37474F", # i
"#AD1457", # j
],
"bar_green": "#2E7D32",
"bar_red": "#C62828",
}
def apply_theme(**overrides) -> dict:
"""
Override theme values for all charts in this session.
Accepts any THEME key. ``pie_colors`` may be a list (used in order) or
a dict keyed ``"a"````"j"`` (sorted alphabetically). Returns the
active theme. Example::
from core.notebook_helpers import charts
charts.apply_theme(
heading_font="Georgia, serif",
font_color="#102A43",
pie_colors={"a": "#1565C0", "b": "#2E7D32"},
bar_green="#1B5E20",
bar_red="#B71C1C",
)
"""
for key, value in overrides.items():
if key not in THEME:
raise KeyError(
f"Unknown theme key {key!r}. Valid keys: {sorted(THEME)}"
)
if key == "pie_colors" and isinstance(value, dict):
value = [value[k] for k in sorted(value)]
THEME[key] = value
return THEME
def _themed(fig: go.Figure) -> go.Figure:
"""Apply theme fonts/colours to a figure's layout."""
fig.update_layout(
font={"family": THEME["body_font"], "color": THEME["font_color"]},
title_font={
"family": THEME["heading_font"],
"color": THEME["font_color"],
},
legend_font={"family": THEME["body_font"], "color": THEME["font_color"]},
)
return fig
def cashflow_chart(
yearly_breakdown: list[dict],
@@ -50,13 +114,13 @@ def cashflow_chart(
name="Total benefits",
x=years,
y=benefits,
marker_color=PALETTE["benefits"],
marker_color=THEME["bar_green"],
)
fig.add_bar(
name="Total costs",
x=years,
y=costs,
marker_color=PALETTE["costs"],
marker_color=THEME["bar_red"],
)
fig.add_scatter(
name="Cumulative net benefits",
@@ -72,7 +136,7 @@ def cashflow_chart(
legend={"orientation": "h", "y": -0.15},
margin={"l": 40, "r": 20, "t": 60, "b": 40},
)
return fig
return _themed(fig)
def benefits_bar(items: list[dict], *, title: str = "Benefits (Three-Year)") -> go.Figure:
@@ -91,7 +155,7 @@ def benefits_bar(items: list[dict], *, title: str = "Benefits (Three-Year)") ->
x=totals,
y=labels,
orientation="h",
marker_color=PALETTE["benefits"],
marker_color=THEME["bar_green"],
text=[f"${t/1_000_000:,.1f}M" for t in totals],
textposition="auto",
)
@@ -102,7 +166,7 @@ def benefits_bar(items: list[dict], *, title: str = "Benefits (Three-Year)") ->
yaxis={"autorange": "reversed"},
margin={"l": 40, "r": 20, "t": 60, "b": 40},
)
return fig
return _themed(fig)
def cost_breakdown_pie(
@@ -122,9 +186,64 @@ def cost_breakdown_pie(
labels.append(it.get("label", "") or it.get("field_key", ""))
values.append(ra_total)
fig = go.Figure(go.Pie(labels=labels, values=values, hole=0.35))
fig = go.Figure(go.Pie(labels=labels, values=values, hole=0.35,
marker={"colors": THEME["pie_colors"]}))
fig.update_layout(title=title, margin={"l": 40, "r": 20, "t": 60, "b": 40})
return fig
return _themed(fig)
def benefits_vs_costs_by_year(
benefit_items: list[dict],
cost_items: list[dict],
*,
title: str = "Benefits vs Costs by Year (Risk-Adjusted)",
) -> go.Figure:
"""
Grouped bars of risk-adjusted benefits and costs per year, with an
Initial (Year 0) column for one-time costs.
Accepts the friendly value rows from ``TEIClient.get_values``:
benefit values are nominal (field-level risk adjustment applied here);
cost values are stored already risk-adjusted (Palladium convention),
with ``initial`` carrying the Year-0 amount.
"""
years: set[int] = set()
for it in [*benefit_items, *cost_items]:
years.update(int(y) for y in (it.get("year_values") or {}))
year_list = sorted(years) or [1, 2, 3]
benefits_by_year: dict[int, float] = dict.fromkeys(year_list, 0.0)
costs_by_year: dict[int, float] = dict.fromkeys(year_list, 0.0)
initial_total = 0.0
for it in benefit_items:
rf = float(it.get("risk_adjustment") or 0.0)
for y, v in (it.get("year_values") or {}).items():
benefits_by_year[int(y)] += float(v or 0) * (1.0 - rf)
for it in cost_items:
initial_total += float(it.get("initial") or 0.0)
for y, v in (it.get("year_values") or {}).items():
costs_by_year[int(y)] += float(v or 0)
x = ["Initial"] + [f"Year {y}" for y in year_list]
benefits = [0.0] + [benefits_by_year[y] for y in year_list]
costs = [initial_total] + [costs_by_year[y] for y in year_list]
fig = go.Figure()
fig.add_bar(name="Benefits", x=x, y=benefits, marker_color=THEME["bar_green"],
text=[f"${v/1_000_000:,.1f}M" if v else "" for v in benefits],
textposition="outside")
fig.add_bar(name="Costs", x=x, y=costs, marker_color=THEME["bar_red"],
text=[f"${v/1_000_000:,.1f}M" if v else "" for v in costs],
textposition="outside")
fig.update_layout(
title=title,
barmode="group",
yaxis_tickformat="$,.0f",
legend={"orientation": "h", "y": -0.15},
margin={"l": 40, "r": 20, "t": 60, "b": 40},
)
return _themed(fig)
def scenario_comparison(scenarios: dict) -> go.Figure:
@@ -137,8 +256,8 @@ def scenario_comparison(scenarios: dict) -> go.Figure:
npvs = [float(scenarios[k].get("npv") or 0) for k in keys]
fig = go.Figure()
fig.add_bar(name="Benefits PV", x=keys, y=benefits, marker_color=PALETTE["benefits"])
fig.add_bar(name="Costs PV", x=keys, y=costs, marker_color=PALETTE["costs"])
fig.add_bar(name="Benefits PV", x=keys, y=benefits, marker_color=THEME["bar_green"])
fig.add_bar(name="Costs PV", x=keys, y=costs, marker_color=THEME["bar_red"])
fig.add_bar(name="NPV", x=keys, y=npvs, marker_color=PALETTE["net_positive"])
fig.update_layout(
title="Scenario Comparison",
@@ -146,7 +265,7 @@ def scenario_comparison(scenarios: dict) -> go.Figure:
yaxis_tickformat="$,.0f",
legend={"orientation": "h", "y": -0.15},
)
return fig
return _themed(fig)
def cumulative_benefits_chart(
@@ -169,7 +288,7 @@ def cumulative_benefits_chart(
)
)
fig.update_layout(title=title, yaxis_tickformat="$,.0f")
return fig
return _themed(fig)
def waterfall(values: Iterable[tuple[str, float]], *, title: str = "TEI Waterfall") -> go.Figure:
@@ -187,7 +306,10 @@ def waterfall(values: Iterable[tuple[str, float]], *, title: str = "TEI Waterfal
measure=measures,
text=[f"${v/1_000_000:,.1f}M" for v in amounts],
textposition="outside",
increasing={"marker": {"color": THEME["bar_green"]}},
decreasing={"marker": {"color": THEME["bar_red"]}},
totals={"marker": {"color": PALETTE["net_positive"]}},
)
)
fig.update_layout(title=title, yaxis_tickformat="$,.0f")
return fig
return _themed(fig)

View File

@@ -0,0 +1,51 @@
# Genesys CX Cloud TEI — December 2025
Source: Forrester, *The Total Economic Impact™ Of CX Cloud — Cost Savings And
Business Benefits Enabled By Genesys And Salesforce* (commissioned by Genesys
and Salesforce, December 2025). PDF in `docs/`.
## Headline (published, 3-yr risk-adjusted PV @ 10%)
| Metric | Value |
|---|---|
| Benefits PV | $14,840,638 |
| Costs PV | $4,057,170 |
| **NPV** | **$10,783,468** |
| **ROI** | **266%** |
| Payback | ~4 months (computed; not headlined in the study) |
Composite: global supply company, $2.5B revenue, 10,000 employees, 600 CX
agents (400 concurrent licenses), 80,000 weekly interactions @ 12 min.
## Structure
4 benefits (legacy retirement ↓5%, self-service savings ↓15%, agent
efficiency ↓10%, agent-assist sales ↓5%) and 3 published costs (licenses ↑5%,
implementation ↑10% — initial-only, ongoing management ↑10%), **plus one
Palladium addition**: `genesys_ai_tokens`, an AI Experience token consumption
line the published study omits (it models $0 AI cost while three of four
benefits depend on AI). Stored exactly as Athena stores it — a single annual
cost value, entered from the Genesys quote in `01_business_case.ipynb` (which
includes a sensitivity sweep), with quote details kept in the field notes.
Seeded at $0 to reproduce the published totals.
## Study quirks (documented, handled)
- p.14 prints implementation initial as $1,304,600; correct figure is
$1,309,000 (= 1,190,000 × 1.10) per the detail table and cash-flow analysis.
- B7's printed formula cites B2 (15%) where the 12-minute interaction length
is meant; the result (40 FTEs) is correct.
- The initial cost is ~32% of cost PV, so Athena's discount-initial-as-Year-1
behaviour shifts ROI to ~277%. Verification matches `ATHENA_EXPECTED`
tightly, then reconciles to `PUBLISHED` with this explained delta.
## Notebooks
| Notebook | Purpose |
|---|---|
| `00_provision.ipynb` | Create template + fields + tool in Athena (client/proposal selection), seed, calculate, verify |
| `01_business_case.ipynb` | Working business case + Genesys AI token quantity × price sensitivity |
Env keys are study-scoped: `PALLADIUM_GENESYSCX_REPORT_PUBLIC_ID`,
`PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID`, `PALLADIUM_GENESYSCX_PROPOSAL_ID` /
`PALLADIUM_GENESYSCX_ENGAGEMENT_ID`.

View File

View File

@@ -0,0 +1,38 @@
"""
Study configuration for the Genesys CX Cloud TEI (Forrester, December 2025).
Env keys are *study-scoped* (PALLADIUM_GENESYSCX_*) so this study can coexist
with the Amazon Connect tool IDs in the same .env. 00_provision.ipynb writes
them for you.
"""
from __future__ import annotations
import os
#: Human-friendly study identifier — used in export metadata + filenames.
STUDY_SLUG = "202512_GenesysCX"
def _int_env(name: str) -> int | None:
raw = os.getenv(name, "").strip()
return int(raw) if raw else None
#: TEI Report template public_id (12-char short UUID).
REPORT_PUBLIC_ID: str = os.getenv("PALLADIUM_GENESYSCX_REPORT_PUBLIC_ID", "")
#: TEI Tool instance public_id.
TOOL_PUBLIC_ID: str = os.getenv("PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID", "")
#: Default discount rate used for local validation of the study numbers.
DISCOUNT_RATE = 0.10
#: Analysis horizon (years).
ANALYSIS_YEARS = 3
#: Athena Proposal PK (a TEI tool attaches to a Proposal OR an Engagement).
PROPOSAL_ID: int | None = _int_env("PALLADIUM_GENESYSCX_PROPOSAL_ID")
#: Athena Engagement PK (alternative attachment point).
ENGAGEMENT_ID: int | None = _int_env("PALLADIUM_GENESYSCX_ENGAGEMENT_ID")

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

View File

@@ -0,0 +1,929 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "41520e77",
"metadata": {},
"source": [
"# 00 · Provision — Genesys CX Cloud TEI in Athena\n",
"\n",
"Source study: Forrester, *The Total Economic Impact™ Of CX Cloud* (Genesys +\n",
"Salesforce, December 2025). Published headline: **NPV \\$10.78M · ROI 266%**.\n",
"\n",
"This notebook creates everything the study needs in the Athena sandbox:\n",
"\n",
"1. **Report template** *CX Cloud (Genesys + Salesforce) 2025* + **field definitions** — 4 benefits, 3 published costs, **plus the `genesys_ai_tokens` consumption line the published study omits**\n",
"2. **Client selection** from the CRM (profile pulled, no re-entry)\n",
"3. **Attachment** to a Proposal or Engagement\n",
"4. **Seed values** + server-side **calculation**\n",
"5. **Two-tier verification**: exact match vs Athena-methodology expectations, then reconciliation to the published totals (explained Year-0 discounting delta)\n",
"6. Persists study-scoped IDs (`PALLADIUM_GENESYSCX_*`) to `.env`\n",
"\n",
"Safe to re-run — every step finds existing objects before creating new ones."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "1b6f1117",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"✅ Athena connected — https://athena.ouranos.helu.ca (1 report templates visible)\n",
"📁 Study: 202512_GenesysCX\n"
]
}
],
"source": [
"import sys, pathlib # path shim: works on a fresh kernel\n",
"for _p in [pathlib.Path.cwd(), *pathlib.Path.cwd().parents]:\n",
" if (_p / \"pyproject.toml\").exists():\n",
" sys.path.insert(0, str(_p)); break\n",
"\n",
"import pandas as pd\n",
"from core.bootstrap import init, update_env\n",
"\n",
"pal = init(study=\"202512_GenesysCX\")\n",
"client, seed, config = pal.client, pal.seed_data, pal.config\n",
"assert pal.connection.get(\"status\") == \"ok\", \"Fix the connection first → 00_setup.ipynb\""
]
},
{
"cell_type": "markdown",
"id": "c1f8b6bd",
"metadata": {},
"source": [
"## 1 · Report template (find or create)"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "cc81e408",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Created report template UCb2hSJprSBx\n"
]
}
],
"source": [
"REPORT_NAME, VENDOR = \"CX Cloud (Genesys + Salesforce) 2025\", \"Genesys\"\n",
"\n",
"report = next(\n",
" (r for r in client.list_reports()\n",
" if r.get(\"name\") == REPORT_NAME and r.get(\"vendor\") == VENDOR),\n",
" None,\n",
")\n",
"if report is None:\n",
" report = client.create_report(\n",
" name=REPORT_NAME,\n",
" vendor=VENDOR,\n",
" version=\"1.0\",\n",
" description=(\n",
" \"Forrester TEI of CX Cloud (Genesys + Salesforce), Dec 2025. \"\n",
" \"Includes Palladium's genesys_ai_tokens consumption line, \"\n",
" \"which the published study omits.\"\n",
" ),\n",
" analysis_period_years=seed.ASSUMPTIONS[\"analysis_years\"],\n",
" discount_rate=seed.ASSUMPTIONS[\"discount_rate\"],\n",
" status=\"draft\",\n",
" )\n",
" print(f\"Created report template {report['id']}\")\n",
"else:\n",
" print(f\"Found existing report template {report['id']} (status: {report.get('status')})\")\n",
"\n",
"REPORT_ID = report[\"id\"]"
]
},
{
"cell_type": "markdown",
"id": "e31bbd8b",
"metadata": {},
"source": [
"## 2 · Field definitions\n",
"\n",
"Same Palladium conventions as the Amazon Connect study: benefit risk\n",
"adjustments live on the field; cost values get pushed pre-multiplied by\n",
"`(1 + risk_adj)`; Year-0 amounts use companion `*_initial` fields.\n",
"The `genesys_ai_tokens` line is seeded \\$0 (reproduces the published study) —\n",
"the annual cost gets entered per deal, from the Genesys quote, in\n",
"`01_business_case.ipynb`."
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "55e69828",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"12 fields created, 0 already existed.\n",
"Report template activated.\n"
]
}
],
"source": [
"def field_defs():\n",
" defs, sort = [], 0\n",
" for b in seed.BENEFITS:\n",
" sort += 1\n",
" defs.append({\n",
" \"table\": \"benefits\",\n",
" \"field_key\": b[\"field_key\"],\n",
" \"label\": b[\"label\"],\n",
" \"description\": b[\"notes\"][:200],\n",
" \"field_type\": \"currency\",\n",
" \"category\": b[\"category\"],\n",
" \"is_annual\": True,\n",
" \"risk_adjustment\": str(b[\"risk_adjustment\"]),\n",
" \"sort_order\": sort,\n",
" \"is_required\": True,\n",
" \"source_notes\": b[\"notes\"],\n",
" })\n",
" for c in seed.COSTS:\n",
" sort += 1\n",
" defs.append({\n",
" \"table\": \"costs\",\n",
" \"field_key\": c[\"field_key\"],\n",
" \"label\": c[\"label\"],\n",
" \"description\": c[\"notes\"][:200],\n",
" \"field_type\": \"currency\",\n",
" \"category\": c[\"category\"],\n",
" \"is_annual\": True,\n",
" \"risk_adjustment\": \"0\", # cost risk adj applied client-side\n",
" \"sort_order\": sort,\n",
" \"is_required\": False,\n",
" \"source_notes\": c[\"notes\"],\n",
" })\n",
" sort += 1\n",
" defs.append({\n",
" \"table\": \"costs\",\n",
" \"field_key\": f\"{c['field_key']}_initial\",\n",
" \"label\": f\"{c['label']} — initial (Year 0)\",\n",
" \"description\": \"One-time Year-0 amount (companion field).\",\n",
" \"field_type\": \"currency\",\n",
" \"category\": c[\"category\"],\n",
" \"is_annual\": False,\n",
" \"risk_adjustment\": \"0\",\n",
" \"sort_order\": sort,\n",
" \"is_required\": False,\n",
" \"source_notes\": \"Year-0 lump sum; Athena treats non-annual values as Year 1.\",\n",
" })\n",
" return defs\n",
"\n",
"existing = {f[\"field_key\"] for f in client.list_fields(REPORT_ID)}\n",
"created = 0\n",
"for d in field_defs():\n",
" if d[\"field_key\"] not in existing:\n",
" client.create_field(REPORT_ID, d)\n",
" created += 1\n",
"print(f\"{created} fields created, {len(existing)} already existed.\")\n",
"\n",
"if report.get(\"status\") == \"draft\":\n",
" client.update_report(REPORT_ID, status=\"active\")\n",
" print(\"Report template activated.\")"
]
},
{
"cell_type": "markdown",
"id": "96b360d3",
"metadata": {},
"source": [
"## 3 · Select the client"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "5a0a701f",
"metadata": {},
"outputs": [
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>id</th>\n",
" <th>name</th>\n",
" <th>vertical</th>\n",
" <th>client_type</th>\n",
" <th>employee_count</th>\n",
" <th>contact_center_agent_count</th>\n",
" <th>supervisor_count</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>2</td>\n",
" <td>Global Guardian Insurance</td>\n",
" <td>None</td>\n",
" <td>For-Profit</td>\n",
" <td>12000</td>\n",
" <td>2500</td>\n",
" <td>None</td>\n",
" </tr>\n",
" <tr>\n",
" <th>1</th>\n",
" <td>3</td>\n",
" <td>Eudaimonix</td>\n",
" <td>None</td>\n",
" <td>For-Profit</td>\n",
" <td>1500</td>\n",
" <td>300</td>\n",
" <td>None</td>\n",
" </tr>\n",
" <tr>\n",
" <th>2</th>\n",
" <td>4</td>\n",
" <td>Aetherium Forge</td>\n",
" <td>None</td>\n",
" <td>For-Profit</td>\n",
" <td>500</td>\n",
" <td>42</td>\n",
" <td>None</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" id name vertical client_type employee_count \\\n",
"0 2 Global Guardian Insurance None For-Profit 12000 \n",
"1 3 Eudaimonix None For-Profit 1500 \n",
"2 4 Aetherium Forge None For-Profit 500 \n",
"\n",
" contact_center_agent_count supervisor_count \n",
"0 2500 None \n",
"1 300 None \n",
"2 42 None "
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"CLIENT_SEARCH = \"\" # e.g. \"Acme\" — empty lists everyone\n",
"\n",
"clients = client.list_clients(search=CLIENT_SEARCH or None)\n",
"if clients:\n",
" display(pd.DataFrame(clients)[\n",
" [c for c in (\"id\", \"name\", \"vertical\", \"client_type\", \"employee_count\",\n",
" \"contact_center_agent_count\", \"supervisor_count\")\n",
" if c in clients[0]]\n",
" ])\n",
"else:\n",
" print(\"No clients found — create one in the Athena UI (Orbit → Clients) and re-run.\")"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "1e375b54",
"metadata": {},
"outputs": [
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>Global Guardian Insurance</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>id</th>\n",
" <td>2</td>\n",
" </tr>\n",
" <tr>\n",
" <th>name</th>\n",
" <td>Global Guardian Insurance</td>\n",
" </tr>\n",
" <tr>\n",
" <th>abbreviated_name</th>\n",
" <td>GGI</td>\n",
" </tr>\n",
" <tr>\n",
" <th>vertical</th>\n",
" <td>None</td>\n",
" </tr>\n",
" <tr>\n",
" <th>client_type</th>\n",
" <td>For-Profit</td>\n",
" </tr>\n",
" <tr>\n",
" <th>employee_count</th>\n",
" <td>12000</td>\n",
" </tr>\n",
" <tr>\n",
" <th>revenue</th>\n",
" <td>4500000000.0</td>\n",
" </tr>\n",
" <tr>\n",
" <th>contact_center_agent_count</th>\n",
" <td>2500</td>\n",
" </tr>\n",
" <tr>\n",
" <th>service_desk_agent_count</th>\n",
" <td>300</td>\n",
" </tr>\n",
" <tr>\n",
" <th>supervisor_count</th>\n",
" <td>None</td>\n",
" </tr>\n",
" <tr>\n",
" <th>location_count</th>\n",
" <td>120</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" Global Guardian Insurance\n",
"id 2\n",
"name Global Guardian Insurance\n",
"abbreviated_name GGI\n",
"vertical None\n",
"client_type For-Profit\n",
"employee_count 12000\n",
"revenue 4500000000.0\n",
"contact_center_agent_count 2500\n",
"service_desk_agent_count 300\n",
"supervisor_count None\n",
"location_count 120"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"CRM agent count: 2500 (composite: 600) — indicative scale 4.17×\n",
"CRM revenue: $4,500,000,000 (composite: $2,500,000,000)\n"
]
}
],
"source": [
"CLIENT_ID = 2 # ← set from the `id` column above, or leave for auto-pick\n",
"\n",
"if CLIENT_ID is None and len(clients) == 1:\n",
" CLIENT_ID = clients[0][\"id\"]\n",
" print(f\"Auto-selected the only client: {clients[0]['name']} (id={CLIENT_ID})\")\n",
"assert CLIENT_ID is not None, \"Set CLIENT_ID from the table above and re-run this cell.\"\n",
"\n",
"profile = client.client_profile(CLIENT_ID)\n",
"CLIENT_NAME = profile[\"name\"]\n",
"display(pd.DataFrame([profile]).T.rename(columns={0: CLIENT_NAME}))\n",
"\n",
"# Client data → study scaling levers (no re-entry)\n",
"CLIENT_ASSUMPTIONS = dict(seed.ASSUMPTIONS)\n",
"if profile.get(\"contact_center_agent_count\"):\n",
" CLIENT_ASSUMPTIONS[\"agents_fte\"] = profile[\"contact_center_agent_count\"]\n",
" scale = CLIENT_ASSUMPTIONS[\"agents_fte\"] / seed.ASSUMPTIONS[\"agents_fte\"]\n",
" print(f\"CRM agent count: {CLIENT_ASSUMPTIONS['agents_fte']} \"\n",
" f\"(composite: {seed.ASSUMPTIONS['agents_fte']}) — \"\n",
" f\"indicative scale {scale:.2f}×\")\n",
"if profile.get(\"revenue\"):\n",
" CLIENT_ASSUMPTIONS[\"annual_revenue\"] = float(profile[\"revenue\"])\n",
" print(f\"CRM revenue: ${CLIENT_ASSUMPTIONS['annual_revenue']:,.0f} \"\n",
" f\"(composite: ${seed.ASSUMPTIONS['annual_revenue']:,.0f})\")"
]
},
{
"cell_type": "markdown",
"id": "2ff83486",
"metadata": {},
"source": [
"## 4 · Pick the attachment — Proposal or Engagement"
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "584e01dd",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Proposals for Global Guardian Insurance:\n"
]
},
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>id</th>\n",
" <th>name</th>\n",
" <th>status</th>\n",
" <th>opportunity</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>1</td>\n",
" <td>Secure Cloud Infrastructure Modernization</td>\n",
" <td>Draft</td>\n",
" <td>Secure Cloud Infrastructure Modernization</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" id name status \\\n",
"0 1 Secure Cloud Infrastructure Modernization Draft \n",
"\n",
" opportunity \n",
"0 Secure Cloud Infrastructure Modernization "
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"proposals = client.proposals_for_client(CLIENT_ID)\n",
"engagements = client.engagements_for_client(CLIENT_NAME)\n",
"\n",
"if proposals:\n",
" print(f\"Proposals for {CLIENT_NAME}:\")\n",
" display(pd.DataFrame([\n",
" {\"id\": p[\"id\"], \"name\": p.get(\"name\"), \"status\": p.get(\"status\"),\n",
" \"opportunity\": (p.get(\"opportunity\") or {}).get(\"name\")}\n",
" for p in proposals\n",
" ]))\n",
"if engagements:\n",
" print(f\"Engagements for {CLIENT_NAME}:\")\n",
" display(pd.DataFrame([\n",
" {\"id\": e[\"id\"], \"name\": e.get(\"name\"), \"status\": e.get(\"status\")}\n",
" for e in engagements\n",
" ]))\n",
"if not proposals and not engagements:\n",
" print(f\"{CLIENT_NAME} has no proposals or engagements yet — \"\n",
" \"the next cell can create a sandbox opportunity + proposal.\")"
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "e04b1676",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Auto-selected proposal 1: Secure Cloud Infrastructure Modernization\n",
"Attaching via: {'proposal': 1}\n"
]
}
],
"source": [
"# Set exactly ONE (ids from above). Leave both None to auto-pick — a single\n",
"# existing option wins; otherwise a sandbox opportunity + proposal is created.\n",
"PROPOSAL_ID = config.PROPOSAL_ID # or e.g. 42\n",
"ENGAGEMENT_ID = config.ENGAGEMENT_ID # or e.g. 7\n",
"\n",
"if PROPOSAL_ID is None and ENGAGEMENT_ID is None:\n",
" if len(proposals) == 1 and not engagements:\n",
" PROPOSAL_ID = proposals[0][\"id\"]\n",
" print(f\"Auto-selected proposal {PROPOSAL_ID}: {proposals[0].get('name')}\")\n",
" elif len(engagements) == 1 and not proposals:\n",
" ENGAGEMENT_ID = engagements[0][\"id\"]\n",
" print(f\"Auto-selected engagement {ENGAGEMENT_ID}: {engagements[0].get('name')}\")\n",
" elif not proposals and not engagements:\n",
" opp = client.create_opportunity(\n",
" name=f\"{CLIENT_NAME} — CX Cloud Modernization (sandbox)\",\n",
" client_id=CLIENT_ID,\n",
" description=\"Created by Palladium 00_provision for the Genesys CX Cloud TEI.\",\n",
" )\n",
" prop = client.create_proposal(\n",
" name=f\"{CLIENT_NAME} — Genesys CX Cloud TEI (sandbox)\",\n",
" opportunity_id=opp[\"id\"],\n",
" status=\"Draft\",\n",
" )\n",
" PROPOSAL_ID = prop[\"id\"]\n",
" print(f\"Created opportunity {opp['id']} and proposal {PROPOSAL_ID} for {CLIENT_NAME}.\")\n",
" else:\n",
" raise SystemExit(\"Multiple options — set PROPOSAL_ID or ENGAGEMENT_ID above and re-run.\")\n",
"\n",
"assert (PROPOSAL_ID is None) != (ENGAGEMENT_ID is None), \\\n",
" \"Set exactly one of PROPOSAL_ID / ENGAGEMENT_ID.\"\n",
"attach = {\"proposal\": PROPOSAL_ID} if PROPOSAL_ID else {\"engagement\": ENGAGEMENT_ID}\n",
"print(f\"Attaching via: {attach}\")"
]
},
{
"cell_type": "markdown",
"id": "2b4fcb45",
"metadata": {},
"source": [
"## 5 · Tool instance & seed the published values"
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "0655d1fc",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Created tool 3rzDgVdsjhVv attached to {'proposal': 1}\n"
]
}
],
"source": [
"from core.tei_client import AthenaAPIError\n",
"\n",
"def _report_id_of(t):\n",
" r = t.get(\"report\")\n",
" return r.get(\"id\") if isinstance(r, dict) else r\n",
"\n",
"def _matches_attachment(t):\n",
" if PROPOSAL_ID is not None:\n",
" opp = t.get(\"opportunity\") or {}\n",
" return t.get(\"proposal\") == PROPOSAL_ID or opp.get(\"proposal_id\") == PROPOSAL_ID\n",
" eng = t.get(\"engagement\")\n",
" eng_id = eng.get(\"id\") if isinstance(eng, dict) else eng\n",
" return eng_id == ENGAGEMENT_ID\n",
"\n",
"candidates = [t for t in client.list_tools() if _report_id_of(t) == REPORT_ID]\n",
"tool = next((t for t in candidates if _matches_attachment(t)),\n",
" candidates[0] if len(candidates) == 1 else None)\n",
"\n",
"if tool is None:\n",
" try:\n",
" tool = client.create_tool(\n",
" report_public_id=REPORT_ID,\n",
" name=f\"{CLIENT_NAME} — Genesys CX Cloud TEI\",\n",
" **attach,\n",
" )\n",
" print(f\"Created tool {tool['id']} attached to {attach}\")\n",
" except AthenaAPIError as e:\n",
" if e.status_code == 409: # DUPLICATE_INSTANCE\n",
" raise SystemExit(\n",
" \"An active tool already exists for this report + attachment. \"\n",
" \"Find it with client.list_tools() or pick a different proposal/engagement.\"\n",
" ) from e\n",
" raise\n",
"else:\n",
" print(f\"Found existing tool {tool['id']} (status: {tool.get('status')})\")\n",
"\n",
"TOOL_ID = tool[\"id\"]"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "86443d76",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Pushed values for 8 fields (genesys_ai_tokens seeded at $0 — published-study baseline).\n"
]
}
],
"source": [
"payload = []\n",
"for b in seed.BENEFITS: # nominal; Athena risk-adjusts via the field definition\n",
" payload.append({\n",
" \"field_key\": b[\"field_key\"],\n",
" \"year_values\": b[\"year_values\"],\n",
" \"notes\": b[\"notes\"],\n",
" })\n",
"for c in seed.COSTS: # risk-adjusted UP client-side (Forrester methodology)\n",
" factor = 1 + c[\"risk_adjustment\"]\n",
" payload.append({\n",
" \"field_key\": c[\"field_key\"],\n",
" \"year_values\": {y: round(v * factor, 2) for y, v in c[\"year_values\"].items()},\n",
" \"initial\": round(c[\"initial\"] * factor, 2),\n",
" \"notes\": c[\"notes\"],\n",
" })\n",
"\n",
"client.update_values(TOOL_ID, payload)\n",
"print(f\"Pushed values for {len(payload)} fields \"\n",
" f\"(genesys_ai_tokens seeded at $0 — published-study baseline).\")"
]
},
{
"cell_type": "markdown",
"id": "509b52be",
"metadata": {},
"source": [
"## 6 · Calculate & verify\n",
"\n",
"**Tier 1 — pipeline correctness:** Athena must match `seed.ATHENA_EXPECTED`\n",
"(the published model re-discounted under Athena's Year-0-as-Year-1 rule)\n",
"within 0.5%.\n",
"\n",
"**Tier 2 — reconciliation:** show Athena vs the published totals. The\n",
"implementation initial (\\$1.309M, ~32% of cost PV) is discounted by Athena\n",
"but not by Forrester, so costs PV reads ~\\$119k lower and ROI ~11pp higher\n",
"than published. That delta is methodology, not data error."
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "0728b42e",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"════════════════════════════════════════════════════════\n",
" TEI Financial Summary\n",
"════════════════════════════════════════════════════════\n",
" Total Benefits (PV): $ 14,840,637\n",
" Total Costs (PV): $ 3,938,170\n",
"────────────────────────────────────────────────────────\n",
" Net Present Value: $ 10,902,466\n",
" ROI: 277%\n",
" Payback: 4.0 months\n",
"════════════════════════════════════════════════════════\n"
]
}
],
"source": [
"summary = client.calculate(TOOL_ID)\n",
"client.print_summary(TOOL_ID)"
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "aba8fc21",
"metadata": {},
"outputs": [
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>metric</th>\n",
" <th>published (Forrester)</th>\n",
" <th>expected (Athena methodology)</th>\n",
" <th>athena actual</th>\n",
" <th>vs expected</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>total_benefits_pv</td>\n",
" <td>14,840,638</td>\n",
" <td>14,840,640</td>\n",
" <td>14,840,637</td>\n",
" <td>-0.00%</td>\n",
" </tr>\n",
" <tr>\n",
" <th>1</th>\n",
" <td>total_costs_pv</td>\n",
" <td>4,057,170</td>\n",
" <td>3,938,170</td>\n",
" <td>3,938,170</td>\n",
" <td>+0.00%</td>\n",
" </tr>\n",
" <tr>\n",
" <th>2</th>\n",
" <td>net_present_value</td>\n",
" <td>10,783,468</td>\n",
" <td>10,902,470</td>\n",
" <td>10,902,466</td>\n",
" <td>-0.00%</td>\n",
" </tr>\n",
" <tr>\n",
" <th>3</th>\n",
" <td>roi_percentage</td>\n",
" <td>266</td>\n",
" <td>277</td>\n",
" <td>277</td>\n",
" <td>+0.01%</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" metric published (Forrester) expected (Athena methodology) \\\n",
"0 total_benefits_pv 14,840,638 14,840,640 \n",
"1 total_costs_pv 4,057,170 3,938,170 \n",
"2 net_present_value 10,783,468 10,902,470 \n",
"3 roi_percentage 266 277 \n",
"\n",
" athena actual vs expected \n",
"0 14,840,637 -0.00% \n",
"1 3,938,170 +0.00% \n",
"2 10,902,466 -0.00% \n",
"3 277 +0.01% "
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"Payback: 4 months (expected ≈ 4)\n",
"✅ Tier 1 passed — pipeline reproduces the study under Athena's discounting.\n",
" Tier 2: published ROI 266% vs Athena ~277% — explained Year-0 delta (see above).\n"
]
}
],
"source": [
"rows, ok = [], True\n",
"for key in (\"total_benefits_pv\", \"total_costs_pv\", \"net_present_value\", \"roi_percentage\"):\n",
" actual = float(summary.get(key) or 0)\n",
" expected = seed.ATHENA_EXPECTED[key]\n",
" published = seed.PUBLISHED[key]\n",
" diff = (actual - expected) / expected\n",
" rows.append({\n",
" \"metric\": key,\n",
" \"published (Forrester)\": f\"{published:,.0f}\",\n",
" \"expected (Athena methodology)\": f\"{expected:,.0f}\",\n",
" \"athena actual\": f\"{actual:,.0f}\",\n",
" \"vs expected\": f\"{diff:+.2%}\",\n",
" })\n",
" ok &= abs(diff) <= 0.005\n",
"\n",
"display(pd.DataFrame(rows))\n",
"print(f\"Payback: {summary.get('payback_period_months')} months (expected ≈ 4)\")\n",
"assert ok, \"Athena diverged >0.5% from its own expected methodology — investigate.\"\n",
"print(\"✅ Tier 1 passed — pipeline reproduces the study under Athena's discounting.\")\n",
"print(\" Tier 2: published ROI 266% vs Athena ~277% — explained Year-0 delta (see above).\")"
]
},
{
"cell_type": "markdown",
"id": "181c7b55",
"metadata": {},
"source": [
"## 7 · Save a baseline version & persist IDs"
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "d8102590",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Saved version 1 (baseline).\n",
"Saved to /Users/robert/git/palladium/.env:\n",
" PALLADIUM_GENESYSCX_REPORT_PUBLIC_ID=UCb2hSJprSBx\n",
" PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID=3rzDgVdsjhVv\n",
" PALLADIUM_GENESYSCX_PROPOSAL_ID=1\n",
"\n",
"Next → 01_business_case.ipynb (AI token cost entry + sensitivity).\n"
]
}
],
"source": [
"if not client.list_versions(TOOL_ID):\n",
" client.save_version(TOOL_ID, note=(\n",
" \"Baseline — published Forrester CX Cloud TEI figures (Dec 2025). \"\n",
" \"genesys_ai_tokens at $0 per the published study; set the annual \"\n",
" \"cost from the Genesys quote in 01_business_case before client use.\"\n",
" ))\n",
" print(\"Saved version 1 (baseline).\")\n",
"\n",
"ids = {\n",
" \"PALLADIUM_GENESYSCX_REPORT_PUBLIC_ID\": REPORT_ID,\n",
" \"PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID\": TOOL_ID,\n",
"}\n",
"if PROPOSAL_ID is not None:\n",
" ids[\"PALLADIUM_GENESYSCX_PROPOSAL_ID\"] = str(PROPOSAL_ID)\n",
"if ENGAGEMENT_ID is not None:\n",
" ids[\"PALLADIUM_GENESYSCX_ENGAGEMENT_ID\"] = str(ENGAGEMENT_ID)\n",
"\n",
"env_path = update_env(**ids)\n",
"print(f\"Saved to {env_path}:\")\n",
"for k, v in ids.items():\n",
" print(f\" {k}={v}\")\n",
"print(\"\\nNext → 01_business_case.ipynb (AI token cost entry + sensitivity).\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "4fc81c99-f073-486a-9f65-f207e96e59cd",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.7"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -0,0 +1,376 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "3da9de99",
"metadata": {},
"source": [
"# 01 · Business Case — Genesys CX Cloud TEI\n",
"\n",
"Working view of the live Athena tool, plus the **Genesys AI Experience token**\n",
"cost line the published study omits. Run `00_provision.ipynb` first.\n",
"\n",
"The published study models \\$0 AI consumption while three of its four benefits\n",
"(self-service uplift, agent efficiency, agent-assist sales) depend on AI\n",
"capabilities that Genesys bills via AI Experience tokens. Token pricing is\n",
"tiered and deal-specific, so the model keeps it simple — **one annual cost\n",
"value, entered from the Genesys quote**, exactly as Athena stores it. Quote\n",
"details (volume, unit price, tier) go in the field notes for the audit trail."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "89deae70",
"metadata": {},
"outputs": [],
"source": [
"import sys, pathlib # path shim: works on a fresh kernel\n",
"for _p in [pathlib.Path.cwd(), *pathlib.Path.cwd().parents]:\n",
" if (_p / \"pyproject.toml\").exists():\n",
" sys.path.insert(0, str(_p)); break\n",
"\n",
"import pandas as pd\n",
"from core.bootstrap import init\n",
"from core.notebook_helpers import charts\n",
"\n",
"pal = init(study=\"202512_GenesysCX\")\n",
"client, seed, config = pal.client, pal.seed_data, pal.config\n",
"\n",
"TOOL_ID = config.TOOL_PUBLIC_ID\n",
"assert TOOL_ID, \"No PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID in .env — run 00_provision.ipynb first.\"\n",
"tool = client.get_tool(TOOL_ID)\n",
"print(f\"Tool: {tool.get('name')} ({TOOL_ID}) status={tool.get('status')}\")"
]
},
{
"cell_type": "markdown",
"id": "09afaf70",
"metadata": {},
"source": [
"## Current financial summary"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3d80c19a",
"metadata": {},
"outputs": [],
"source": [
"summary = client.calculate(TOOL_ID)\n",
"client.print_summary(TOOL_ID)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "392df0ba",
"metadata": {},
"outputs": [],
"source": [
"values = client.get_values(TOOL_ID)\n",
"benefit_rows = [v for v in values if v.get(\"table\") == \"benefits\"]\n",
"cost_rows = [v for v in values if v.get(\"table\") == \"costs\"]\n",
"\n",
"def value_table(rows, *, initial=False):\n",
" out = []\n",
" for v in rows:\n",
" yv = v.get(\"year_values\") or {}\n",
" rec = {\"field\": v.get(\"label\") or v[\"field_key\"]}\n",
" if initial:\n",
" rec[\"Initial\"] = v.get(\"initial\", 0.0)\n",
" rec.update({f\"Year {y}\": yv.get(str(y), 0.0) for y in (1, 2, 3)})\n",
" rec[\"risk_adj\"] = v.get(\"risk_adjustment\")\n",
" out.append(rec)\n",
" return pd.DataFrame(out)\n",
"\n",
"print(\"Benefits (nominal; Athena risk-adjusts at calculate time):\")\n",
"display(value_table(benefit_rows))\n",
"print(\"Costs (stored pre-multiplied by 1 + risk_adj):\")\n",
"display(value_table(cost_rows, initial=True))"
]
},
{
"cell_type": "markdown",
"id": "2383f761",
"metadata": {},
"source": [
"## Financial visualizations\n",
"\n",
"All figures are risk-adjusted and built from the live Athena values, using\n",
"the shared `core.notebook_helpers.charts` helpers (same ones the Streamlit\n",
"app uses)."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Visual theme — plain values, edit freely (not confidential) ──\n",
"THEME = {\n",
" \"heading_font\": \"Helvetica Neue, Arial, sans-serif\", # chart titles\n",
" \"body_font\": \"Helvetica, Arial, sans-serif\", # axes, legends, labels\n",
" \"font_color\": \"#1F2937\", # hex\n",
"\n",
" # Circle-chart slice colours, applied in order a..j\n",
" \"pie_colors\": {\n",
" \"a\": \"#1565C0\",\n",
" \"b\": \"#2E7D32\",\n",
" \"c\": \"#C62828\",\n",
" \"d\": \"#F9A825\",\n",
" \"e\": \"#6A1B9A\",\n",
" \"f\": \"#00838F\",\n",
" \"g\": \"#EF6C00\",\n",
" \"h\": \"#5D4037\",\n",
" \"i\": \"#37474F\",\n",
" \"j\": \"#AD1457\",\n",
" },\n",
"\n",
" \"bar_green\": \"#2E7D32\", # benefits bars\n",
" \"bar_red\": \"#C62828\", # costs bars\n",
"}\n",
"\n",
"charts.apply_theme(**THEME)\n",
"print(\"Theme applied — re-run the chart cells below to restyle.\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "14d5942a",
"metadata": {},
"outputs": [],
"source": [
"# Cost breakdown — share of total three-year cost per line item\n",
"charts.cost_breakdown_pie(\n",
" cost_rows, title=\"Cost Breakdown — share of 3-year total (risk-adjusted)\"\n",
").show()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d13f342e",
"metadata": {},
"outputs": [],
"source": [
"# Benefits — three-year risk-adjusted total per category\n",
"charts.benefits_bar(\n",
" benefit_rows, title=\"Benefits (Three-Year, Risk-Adjusted)\"\n",
").show()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9ef6caee",
"metadata": {},
"outputs": [],
"source": [
"# Benefits vs costs, year by year (Initial = one-time Year-0 costs)\n",
"charts.benefits_vs_costs_by_year(benefit_rows, cost_rows).show()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6f069df6",
"metadata": {},
"outputs": [],
"source": [
"# Cash flow with cumulative net benefits — the Forrester-style exhibit\n",
"def _yearly_breakdown(benefit_items, cost_items):\n",
" \"\"\"Risk-adjusted yearly rows + initial, computed from the live values.\"\"\"\n",
" initial = sum(float(c.get(\"initial\") or 0) for c in cost_items)\n",
" rows, cumulative = [], -initial\n",
" for y in (1, 2, 3):\n",
" b = sum(float((v.get(\"year_values\") or {}).get(str(y), 0) or 0)\n",
" * (1 - float(v.get(\"risk_adjustment\") or 0))\n",
" for v in benefit_items)\n",
" c = sum(float((v.get(\"year_values\") or {}).get(str(y), 0) or 0)\n",
" for v in cost_items)\n",
" cumulative += b - c\n",
" rows.append({\"year\": y, \"benefits\": b, \"costs\": c,\n",
" \"net\": b - c, \"cumulative_net\": cumulative})\n",
" return rows, initial\n",
"\n",
"yb, initial_cost = _yearly_breakdown(benefit_rows, cost_rows)\n",
"charts.cashflow_chart(yb, initial_cost=initial_cost).show()\n",
"pd.DataFrame(yb)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "b1ff0315",
"metadata": {},
"outputs": [],
"source": [
"# Waterfall — Benefits PV down to NPV (from the Athena summary)\n",
"charts.waterfall([\n",
" (\"Benefits PV\", float(summary[\"total_benefits_pv\"])),\n",
" (\"Costs PV\", -float(summary[\"total_costs_pv\"])),\n",
" (\"NPV\", float(summary[\"net_present_value\"])),\n",
"], title=\"Benefits PV → Costs PV → NPV\").show()"
]
},
{
"cell_type": "markdown",
"id": "c78f77a9",
"metadata": {},
"source": [
"## Genesys AI Experience tokens — annual cost\n",
"\n",
"Enter the negotiated annual token cost from the Genesys quote. For sizing\n",
"context, the study's own drivers imply roughly **1,040,000** self-service\n",
"interactions/yr and **3,120,000** agent-assisted interactions/yr would draw\n",
"tokens — bring the actual figure from the quote, not a derivation."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "99b9c665",
"metadata": {},
"outputs": [],
"source": [
"# ── Deal inputs ──\n",
"AI_TOKEN_ANNUAL_COST = 0.0 # $/yr from the Genesys quote — 0 reproduces the published study\n",
"AI_TOKEN_QUOTE_NOTE = \"\" # e.g. \"Quote #1234: 4.2M tokens/yr @ $0.05, tier 2 commit\"\n",
"\n",
"print(f\"AI token line: ${AI_TOKEN_ANNUAL_COST:,.0f}/yr\")"
]
},
{
"cell_type": "markdown",
"id": "b7c6c4c7",
"metadata": {},
"source": [
"### Sensitivity — what the AI line does to NPV and ROI\n",
"\n",
"Computed locally from the current Athena summary: an annual cost `Δ` raises\n",
"costs PV by `Δ × 2.4869` (the 3-year, 10% annuity factor) and lowers NPV by\n",
"the same amount."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "09ceeea2",
"metadata": {},
"outputs": [],
"source": [
"ANNUITY = sum(1 / 1.10**n for n in (1, 2, 3)) # 2.4869\n",
"\n",
"base_benefits_pv = float(summary[\"total_benefits_pv\"])\n",
"base_costs_pv = float(summary[\"total_costs_pv\"])\n",
"\n",
"# Remove any token cost already stored, to get a clean zero-token base.\n",
"current_tokens = next(\n",
" (v for v in values if v[\"field_key\"] == \"genesys_ai_tokens\"), {})\n",
"current_annual = (current_tokens.get(\"year_values\") or {}).get(\"1\", 0.0)\n",
"base_costs_pv -= current_annual * ANNUITY\n",
"\n",
"sweep = [0, 100_000, 250_000, 500_000, 750_000, 1_000_000]\n",
"if AI_TOKEN_ANNUAL_COST and AI_TOKEN_ANNUAL_COST not in sweep:\n",
" sweep = sorted(sweep + [AI_TOKEN_ANNUAL_COST])\n",
"\n",
"rows = []\n",
"for ai_annual in sweep:\n",
" costs_pv = base_costs_pv + ai_annual * ANNUITY\n",
" npv = base_benefits_pv - costs_pv\n",
" rows.append({\n",
" \"AI cost/yr\": f\"${ai_annual:,.0f}\" + (\" ← your input\" if ai_annual == AI_TOKEN_ANNUAL_COST and ai_annual else \"\"),\n",
" \"Costs PV\": f\"${costs_pv:,.0f}\",\n",
" \"NPV\": f\"${npv:,.0f}\",\n",
" \"ROI\": f\"{npv / costs_pv * 100:,.0f}%\",\n",
" })\n",
"\n",
"display(pd.DataFrame(rows))"
]
},
{
"cell_type": "markdown",
"id": "6516270c",
"metadata": {},
"source": [
"## Push the AI token cost to Athena\n",
"\n",
"Writes the annual cost into `genesys_ai_tokens` (quote details in the notes),\n",
"recalculates server-side, and saves a version."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "c6caacea",
"metadata": {},
"outputs": [],
"source": [
"PUSH = False # ← set True once AI_TOKEN_ANNUAL_COST is final\n",
"\n",
"if PUSH:\n",
" note = (\n",
" f\"AI Experience tokens: ${AI_TOKEN_ANNUAL_COST:,.0f}/yr. \"\n",
" + (f\"{AI_TOKEN_QUOTE_NOTE} \" if AI_TOKEN_QUOTE_NOTE else \"\")\n",
" + \"Line absent from the published Forrester study.\"\n",
" )\n",
" client.update_values(TOOL_ID, [{\n",
" \"field_key\": \"genesys_ai_tokens\",\n",
" \"year_values\": {\"1\": round(AI_TOKEN_ANNUAL_COST, 2),\n",
" \"2\": round(AI_TOKEN_ANNUAL_COST, 2),\n",
" \"3\": round(AI_TOKEN_ANNUAL_COST, 2)},\n",
" \"notes\": note,\n",
" }])\n",
" summary = client.calculate(TOOL_ID)\n",
" client.print_summary(TOOL_ID)\n",
" client.save_version(TOOL_ID, note=f\"AI token cost set: {note}\")\n",
" print(\"✅ Pushed, recalculated, and versioned. Re-run the visualization \"\n",
" \"cells above to refresh the charts.\")\n",
"else:\n",
" print(\"Dry run — set PUSH = True to write to Athena.\")"
]
},
{
"cell_type": "markdown",
"id": "c261ad48",
"metadata": {},
"source": [
"## Next steps\n",
"\n",
"- Adjust other drivers for the client (interaction volume, agent count,\n",
" self-service delta) via `client.update_values` or the Streamlit app\n",
" (`make app`), saving a version per scenario.\n",
"- Export charts for a deck: any figure object supports\n",
" `fig.write_image(\"chart.png\")` (needs `pip install kaleido`) or\n",
" `fig.write_html(\"chart.html\")`.\n",
"- Export for the report pipeline:\n",
" `python -m palladium export $PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID -o exports/export.json`"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.7"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -0,0 +1,231 @@
"""
Seed dataset for the Genesys CX Cloud TEI (Forrester, Dec 2025).
"The Total Economic Impact™ Of CX Cloud — Cost Savings And Business
Benefits Enabled By Genesys And Salesforce" (commissioned by Genesys and
Salesforce). Composite: global supply company, $2.5B revenue, 10,000
employees, 600 CX agents (400 concurrent licenses), 80,000 weekly
interactions averaging 12 minutes.
Each row uses the friendly value shape accepted by
``core.tei_client.TEIClient.update_values``. Benefit values are *nominal*
(pre-risk-adjustment); Athena applies the field-level risk adjustment.
Cost values are nominal too — push them pre-multiplied by
``(1 + risk_adjustment)`` per the Palladium convention (Athena never
risk-adjusts costs).
Published headline (3-yr risk-adjusted, 10% discount)::
Benefits PV $14,840,638
Costs PV $ 4,057,170
NPV $10,783,468
ROI 266%
Payback ~4 months (computed; the study does not headline it)
Athena discounts Year-0 "Initial" amounts as Year-1 cashflows (Forrester
leaves Year 0 undiscounted). With this study's large initial cost
($1,309,000 risk-adjusted) that difference is material, so this module
also exports ``ATHENA_EXPECTED`` — the totals Athena *should* produce
under its own discounting. Verification: match ATHENA_EXPECTED tightly
(pipeline correctness), then reconcile to PUBLISHED with the explained
Year-0 delta.
NOTE on the published PDF: the Total Costs table (p.14) prints the
implementation initial as $1,304,600, but the detail table, the cash-flow
analysis, and the math (1,190,000 × 1.10) all give $1,309,000 — the p.14
figure is a typo in the study.
"""
from __future__ import annotations
#: 3-year nominal benefit cashflows. Risk adjustment stored separately.
BENEFITS: list[dict] = [
{
"field_key": "legacy_retirement",
"table": "benefits",
"label": "Retirement of legacy systems with CX Cloud adoption",
"category": "Cost Savings",
"year_values": {"1": 680_000, "2": 930_000, "3": 930_000},
"risk_adjustment": 0.05,
"notes": (
"PDF A1A4. Telephony $250k Y1 ramping to $500k (legacy "
"sunset completes mid-Y1) + WFM/recording/transcription apps "
"$100k + reduced dev effort $230k (2,400 hrs @ $94) + reduced "
"platform mgmt $100k (1,500 hrs @ $65). Risk adj 5%."
),
},
{
"field_key": "self_service_savings",
"table": "benefits",
"label": (
"Cost savings from reallocated workers and avoided seasonal "
"hires with increased customer self-service"
),
"category": "Productivity",
"year_values": {"1": 2_329_600, "2": 2_329_600, "3": 2_329_600},
"risk_adjustment": 0.15,
"notes": (
"PDF B1B8. Self-service completion 15%→25% on 80k weekly "
"interactions → 8,000 deflected/week → 40 FTEs @ $58,240 "
"fully burdened. Risk adj 15%. (PDF B7 formula cites B2 where "
"the 12-min interaction length is meant; 40 FTEs is correct.)"
),
},
{
"field_key": "agent_efficiency",
"table": "benefits",
"label": "CX agent efficiency gains",
"category": "Productivity",
"year_values": {"1": 2_912_000, "2": 2_912_000, "3": 2_912_000},
"risk_adjustment": 0.10,
"notes": (
"PDF C1C6. MTTR 12→10 min on 60k agent-handled interactions "
"per week → 104,000 hrs/yr @ $28 fully burdened. Risk adj 10%."
),
},
{
"field_key": "agent_assist_sales",
"table": "benefits",
"label": "Incremental sales from agent assist capabilities",
"category": "Revenue",
"year_values": {"1": 600_000, "2": 600_000, "3": 600_000},
"risk_adjustment": 0.05,
"notes": (
"PDF D1D3. $500M revenue impacted (20% of $2.5B) × 1.5% lift "
"× 8% gross margin. Risk adj 5%."
),
},
]
#: Costs are nominal; push × (1 + risk_adjustment). "initial" is the
#: Year-0 component (companion non-annual field in Athena).
COSTS: list[dict] = [
{
"field_key": "cx_cloud_licenses",
"table": "costs",
"label": "CX Cloud solution costs (licenses)",
"category": "Subscription",
"initial": 0,
"year_values": {"1": 840_000, "2": 840_000, "3": 840_000},
"risk_adjustment": 0.05,
"notes": (
"PDF E1E3. Genesys Cloud CX 2 $170/user/mo + Salesforce "
"Voice $25/user/mo + connector $25/user/mo, 400 concurrent "
"users, 20% contractual discount → $650k + $95k + $95k. "
"Risk adj +5%. Seat licenses ONLY — AI consumption is a "
"separate line (genesys_ai_tokens)."
),
},
{
"field_key": "implementation",
"table": "costs",
"label": "Implementation and deployment cost",
"category": "Implementation",
"initial": 1_190_000,
"year_values": {"1": 0, "2": 0, "3": 0},
"risk_adjustment": 0.10,
"notes": (
"PDF F1F5. 10-week implementation: 20 FTEs @ $80/hr fully "
"burdened ($640k) + $550k professional services. Risk adj "
"+10% → $1,309,000 (the p.14 Total Costs table's $1,304,600 "
"is a typo in the study)."
),
},
{
"field_key": "ongoing_management",
"table": "costs",
"label": "Ongoing management costs",
"category": "Operations",
"initial": 0,
"year_values": {"1": 202_800, "2": 202_800, "3": 202_800},
"risk_adjustment": 0.10,
"notes": (
"PDF G1G3. 5 people @ 30% time (12 hrs/wk) @ $65/hr. "
"Risk adj +10%."
),
},
{
"field_key": "genesys_ai_tokens",
"table": "costs",
"label": "Genesys AI Experience token consumption",
"category": "Subscription",
"initial": 0,
"year_values": {"1": 0, "2": 0, "3": 0},
"risk_adjustment": 0.0,
"notes": (
"NOT in the published study — Forrester modeled $0 AI "
"consumption even though benefits B (self-service uplift), "
"C (AI coaching/assist), and D (agent assist upsell) all "
"depend on AI capabilities that Genesys bills via AI "
"Experience tokens. Seeded at $0 to reproduce the published "
"totals. For client cases, enter the negotiated annual token "
"cost from the Genesys quote and document the quote details "
"(token volume, unit price, tier) in these notes."
),
},
]
#: Composite-organization drivers — for scaling to a specific client.
ASSUMPTIONS: dict = {
"annual_revenue": 2_500_000_000,
"employees": 10_000,
"agents_fte": 600,
"concurrent_licenses": 400,
"weekly_interactions": 80_000,
"interaction_minutes": 12,
"self_service_rate_before": 0.15,
"self_service_rate_after": 0.25,
"mttr_saved_minutes": 2,
"agent_hourly_rate": 28,
"agent_annual_salary": 58_240,
"revenue_impacted": 500_000_000,
"revenue_lift": 0.015,
"gross_margin": 0.08,
"discount_rate": 0.10,
"analysis_years": 3,
}
# ────────────────────────────────────────────────────────────────────
# Genesys AI Experience tokens
#
# Genesys bills AI consumption in "AI Experience tokens" — pricing is
# tiered, capability-dependent, and deal-specific. Athena stores a
# single annual cost value per line, and so do we: enter the negotiated
# annual figure from the Genesys quote into ``genesys_ai_tokens`` and
# document the quote details (volume, unit price, tier) in the field
# notes. For sizing context, the study's own drivers imply ~1,040,000
# self-service interactions/yr (B5 × 52) and ~3,120,000 agent-assisted
# interactions/yr (C1 × 52) would draw tokens.
# ────────────────────────────────────────────────────────────────────
# ────────────────────────────────────────────────────────────────────
# Verification targets
# ────────────────────────────────────────────────────────────────────
#: Published Forrester totals (3-yr risk-adjusted PV @ 10%).
PUBLISHED: dict = {
"total_benefits_pv": 14_840_638,
"total_costs_pv": 4_057_170,
"net_present_value": 10_783_468,
"roi_percentage": 266,
}
#: What Athena should produce given its own discounting (Year-0 initial
#: treated as a Year-1 cashflow: implementation PV = 1,309,000 / 1.10 =
#: 1,190,000 instead of 1,309,000). Match these tightly; the difference
#: vs PUBLISHED is methodology, not error.
ATHENA_EXPECTED: dict = {
"total_benefits_pv": 14_840_640,
"total_costs_pv": 3_938_170,
"net_present_value": 10_902_470,
"roi_percentage": 276.8,
}
def all_values() -> list[dict]:
"""Return BENEFITS + COSTS — single-call payload for update_values."""
return BENEFITS + COSTS

File diff suppressed because one or more lines are too long