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