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)
|
│ ├── views/ # benefits, costs, summary, versions (NOT `pages/` — avoids Streamlit auto-multipage)
|
||||||
│ └── components/ # tables, charts
|
│ └── components/ # tables, charts
|
||||||
├── studies/ # One folder per TEI engagement
|
├── 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/
|
│ └── 202602_AmazonConnect/
|
||||||
│ ├── README.md
|
│ ├── README.md
|
||||||
│ ├── config.py # TOOL_PUBLIC_ID, REPORT_PUBLIC_ID
|
│ ├── 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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,26 +12,31 @@ import streamlit as st
|
|||||||
from core.notebook_helpers import charts as core_charts
|
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)
|
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)
|
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)
|
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)
|
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)
|
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
|
import streamlit as st
|
||||||
|
|
||||||
|
from app.components import charts
|
||||||
from app.components.tables import df_to_values, value_editor
|
from app.components.tables import df_to_values, value_editor
|
||||||
from app.utils import icon
|
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 "
|
"applied at calculate time. Use the Recalculate button in the "
|
||||||
"sidebar after saving to refresh the summary."
|
"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
|
import streamlit as st
|
||||||
|
|
||||||
|
from app.components import charts
|
||||||
from app.components.tables import df_to_values, value_editor
|
from app.components.tables import df_to_values, value_editor
|
||||||
from app.utils import icon
|
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 "
|
"are end-of-year cashflows. Costs are risk-adjusted upward "
|
||||||
"(higher risk → higher cost)."
|
"(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,10 +56,52 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
# Build the yearly breakdown from the documented per-year summary keys
|
# ── Financial visualizations ────────────────────────────────────
|
||||||
# (benefits_year_N / costs_year_N) when no pre-built breakdown exists.
|
# Built from the live value rows so Year-0 "Initial" amounts stay
|
||||||
yb = summary.get("yearly_breakdown") or []
|
# 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:
|
if not yb:
|
||||||
|
# Fallback: documented per-year summary keys (initial folded in Y1).
|
||||||
n = 1
|
n = 1
|
||||||
while f"benefits_year_{n}" in summary or f"costs_year_{n}" in summary:
|
while f"benefits_year_{n}" in summary or f"costs_year_{n}" in summary:
|
||||||
b = float(summary.get(f"benefits_year_{n}") or 0)
|
b = float(summary.get(f"benefits_year_{n}") or 0)
|
||||||
@@ -68,7 +110,7 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
n += 1
|
n += 1
|
||||||
initial = float(summary.get("initial_costs") or 0)
|
initial = float(summary.get("initial_costs") or 0)
|
||||||
if yb:
|
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"):
|
with st.expander("Cash flow table"):
|
||||||
_cur = currency_fmt()
|
_cur = currency_fmt()
|
||||||
st.dataframe(
|
st.dataframe(
|
||||||
@@ -85,6 +127,14 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
else:
|
else:
|
||||||
st.caption("No yearly breakdown in this summary.")
|
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
|
# Scenario comparison — computed locally from current values
|
||||||
with st.expander("Scenario analysis (conservative / moderate / aggressive)"):
|
with st.expander("Scenario analysis (conservative / moderate / aggressive)"):
|
||||||
envelope = safe(
|
envelope = safe(
|
||||||
@@ -95,7 +145,9 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
study_slug=report.get("name", ""),
|
study_slug=report.get("name", ""),
|
||||||
)
|
)
|
||||||
if envelope and envelope.get("scenarios"):
|
if envelope and envelope.get("scenarios"):
|
||||||
charts.scenario_bars(envelope["scenarios"])
|
charts.scenario_bars(
|
||||||
|
envelope["scenarios"], key=f"summary_scenarios_{public_id}"
|
||||||
|
)
|
||||||
rows = [
|
rows = [
|
||||||
{
|
{
|
||||||
"Scenario": k,
|
"Scenario": k,
|
||||||
|
|||||||
@@ -20,6 +20,70 @@ PALETTE = {
|
|||||||
"cumulative": "#616161", # grey
|
"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(
|
def cashflow_chart(
|
||||||
yearly_breakdown: list[dict],
|
yearly_breakdown: list[dict],
|
||||||
@@ -50,13 +114,13 @@ def cashflow_chart(
|
|||||||
name="Total benefits",
|
name="Total benefits",
|
||||||
x=years,
|
x=years,
|
||||||
y=benefits,
|
y=benefits,
|
||||||
marker_color=PALETTE["benefits"],
|
marker_color=THEME["bar_green"],
|
||||||
)
|
)
|
||||||
fig.add_bar(
|
fig.add_bar(
|
||||||
name="Total costs",
|
name="Total costs",
|
||||||
x=years,
|
x=years,
|
||||||
y=costs,
|
y=costs,
|
||||||
marker_color=PALETTE["costs"],
|
marker_color=THEME["bar_red"],
|
||||||
)
|
)
|
||||||
fig.add_scatter(
|
fig.add_scatter(
|
||||||
name="Cumulative net benefits",
|
name="Cumulative net benefits",
|
||||||
@@ -72,7 +136,7 @@ def cashflow_chart(
|
|||||||
legend={"orientation": "h", "y": -0.15},
|
legend={"orientation": "h", "y": -0.15},
|
||||||
margin={"l": 40, "r": 20, "t": 60, "b": 40},
|
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:
|
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,
|
x=totals,
|
||||||
y=labels,
|
y=labels,
|
||||||
orientation="h",
|
orientation="h",
|
||||||
marker_color=PALETTE["benefits"],
|
marker_color=THEME["bar_green"],
|
||||||
text=[f"${t/1_000_000:,.1f}M" for t in totals],
|
text=[f"${t/1_000_000:,.1f}M" for t in totals],
|
||||||
textposition="auto",
|
textposition="auto",
|
||||||
)
|
)
|
||||||
@@ -102,7 +166,7 @@ def benefits_bar(items: list[dict], *, title: str = "Benefits (Three-Year)") ->
|
|||||||
yaxis={"autorange": "reversed"},
|
yaxis={"autorange": "reversed"},
|
||||||
margin={"l": 40, "r": 20, "t": 60, "b": 40},
|
margin={"l": 40, "r": 20, "t": 60, "b": 40},
|
||||||
)
|
)
|
||||||
return fig
|
return _themed(fig)
|
||||||
|
|
||||||
|
|
||||||
def cost_breakdown_pie(
|
def cost_breakdown_pie(
|
||||||
@@ -122,9 +186,64 @@ def cost_breakdown_pie(
|
|||||||
labels.append(it.get("label", "") or it.get("field_key", ""))
|
labels.append(it.get("label", "") or it.get("field_key", ""))
|
||||||
values.append(ra_total)
|
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})
|
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:
|
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]
|
npvs = [float(scenarios[k].get("npv") or 0) for k in keys]
|
||||||
|
|
||||||
fig = go.Figure()
|
fig = go.Figure()
|
||||||
fig.add_bar(name="Benefits PV", x=keys, y=benefits, marker_color=PALETTE["benefits"])
|
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=PALETTE["costs"])
|
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.add_bar(name="NPV", x=keys, y=npvs, marker_color=PALETTE["net_positive"])
|
||||||
fig.update_layout(
|
fig.update_layout(
|
||||||
title="Scenario Comparison",
|
title="Scenario Comparison",
|
||||||
@@ -146,7 +265,7 @@ def scenario_comparison(scenarios: dict) -> go.Figure:
|
|||||||
yaxis_tickformat="$,.0f",
|
yaxis_tickformat="$,.0f",
|
||||||
legend={"orientation": "h", "y": -0.15},
|
legend={"orientation": "h", "y": -0.15},
|
||||||
)
|
)
|
||||||
return fig
|
return _themed(fig)
|
||||||
|
|
||||||
|
|
||||||
def cumulative_benefits_chart(
|
def cumulative_benefits_chart(
|
||||||
@@ -169,7 +288,7 @@ def cumulative_benefits_chart(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
fig.update_layout(title=title, yaxis_tickformat="$,.0f")
|
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:
|
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,
|
measure=measures,
|
||||||
text=[f"${v/1_000_000:,.1f}M" for v in amounts],
|
text=[f"${v/1_000_000:,.1f}M" for v in amounts],
|
||||||
textposition="outside",
|
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")
|
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