Compare commits
4 Commits
faa7d20b3e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 71b98ee4e4 | |||
| 64fb83257d | |||
| ecd164ee6d | |||
| 253ff38118 |
13
.env.example
13
.env.example
@@ -10,3 +10,16 @@ ATHENA_API_KEY=your-api-key-here
|
|||||||
# PALLADIUM_TOOL_PUBLIC_ID=
|
# PALLADIUM_TOOL_PUBLIC_ID=
|
||||||
# PALLADIUM_PROPOSAL_ID=
|
# PALLADIUM_PROPOSAL_ID=
|
||||||
# PALLADIUM_ENGAGEMENT_ID=
|
# PALLADIUM_ENGAGEMENT_ID=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Locale / display formatting (Streamlit app)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Currency symbol prefix (default: $)
|
||||||
|
# PALLADIUM_CURRENCY_SYMBOL=$
|
||||||
|
#
|
||||||
|
# Thousands separator (default: , for Americas/UK; use . for continental Europe)
|
||||||
|
# PALLADIUM_THOUSANDS_SEP=,
|
||||||
|
#
|
||||||
|
# Decimal separator (default: . for Americas/UK; use , for continental Europe)
|
||||||
|
# PALLADIUM_DECIMAL_SEP=.
|
||||||
|
|
||||||
|
|||||||
107
00_setup.ipynb
107
00_setup.ipynb
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 1,
|
"execution_count": 6,
|
||||||
"id": "53fcc345",
|
"id": "53fcc345",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"Palladium(root='palladium', athena='not tested')"
|
"Palladium(root='palladium', athena='not tested')"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"execution_count": 1,
|
"execution_count": 6,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"output_type": "execute_result"
|
"output_type": "execute_result"
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 2,
|
"execution_count": 7,
|
||||||
"id": "853aaab8",
|
"id": "853aaab8",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 3,
|
"execution_count": 8,
|
||||||
"id": "9b7fcc97",
|
"id": "9b7fcc97",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
"name": "stdout",
|
"name": "stdout",
|
||||||
"output_type": "stream",
|
"output_type": "stream",
|
||||||
"text": [
|
"text": [
|
||||||
"✅ Athena connected — https://athena.ouranos.helu.ca (0 report templates visible)\n"
|
"✅ Athena connected — https://athena.ouranos.helu.ca (1 report templates visible)\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -119,11 +119,11 @@
|
|||||||
"{'status': 'ok',\n",
|
"{'status': 'ok',\n",
|
||||||
" 'base_url': 'https://athena.ouranos.helu.ca',\n",
|
" 'base_url': 'https://athena.ouranos.helu.ca',\n",
|
||||||
" 'authenticated': True,\n",
|
" 'authenticated': True,\n",
|
||||||
" 'reports_found': 0,\n",
|
" 'reports_found': 1,\n",
|
||||||
" 'timestamp': '2026-06-10T06:45:10.418874'}"
|
" 'timestamp': '2026-06-10T07:08:06.947037'}"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"execution_count": 3,
|
"execution_count": 8,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"output_type": "execute_result"
|
"output_type": "execute_result"
|
||||||
}
|
}
|
||||||
@@ -144,16 +144,69 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 4,
|
"execution_count": 9,
|
||||||
"id": "83edbe4d",
|
"id": "83edbe4d",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
"name": "stdout",
|
"data": {
|
||||||
"output_type": "stream",
|
"text/html": [
|
||||||
"text": [
|
"<div>\n",
|
||||||
"No TEI report templates yet — studies/202602_AmazonConnect/notebooks/00_provision.ipynb creates one.\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>vendor</th>\n",
|
||||||
|
" <th>version</th>\n",
|
||||||
|
" <th>status</th>\n",
|
||||||
|
" <th>analysis_period_years</th>\n",
|
||||||
|
" <th>discount_rate</th>\n",
|
||||||
|
" <th>field_count</th>\n",
|
||||||
|
" <th>instance_count</th>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" </thead>\n",
|
||||||
|
" <tbody>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>0</th>\n",
|
||||||
|
" <td>xsUTbjh4iDnJ</td>\n",
|
||||||
|
" <td>Amazon Connect 2026</td>\n",
|
||||||
|
" <td>AWS</td>\n",
|
||||||
|
" <td>1.0</td>\n",
|
||||||
|
" <td>active</td>\n",
|
||||||
|
" <td>3</td>\n",
|
||||||
|
" <td>0.1000</td>\n",
|
||||||
|
" <td>11</td>\n",
|
||||||
|
" <td>0</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" </tbody>\n",
|
||||||
|
"</table>\n",
|
||||||
|
"</div>"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
" id name vendor version status \\\n",
|
||||||
|
"0 xsUTbjh4iDnJ Amazon Connect 2026 AWS 1.0 active \n",
|
||||||
|
"\n",
|
||||||
|
" analysis_period_years discount_rate field_count instance_count \n",
|
||||||
|
"0 3 0.1000 11 0 "
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
@@ -172,7 +225,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 5,
|
"execution_count": 10,
|
||||||
"id": "a247bedd",
|
"id": "a247bedd",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -209,6 +262,30 @@
|
|||||||
"2. **Work the study** → notebooks `01_benefits` → `04_export` in the same folder.\n",
|
"2. **Work the study** → notebooks `01_benefits` → `04_export` in the same folder.\n",
|
||||||
"3. **Interactive data entry** → `make app` (or `streamlit run app/main.py`)."
|
"3. **Interactive data entry** → `make app` (or `streamlit run app/main.py`)."
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "d20d824f-e464-4ff7-8191-10c2495842a0",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "630ee935-7c7b-47e5-9c13-6285316823e2",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "7eba3877-8e51-443f-9953-9d0a48425f9f",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
|
|||||||
@@ -261,9 +261,13 @@ palladium/
|
|||||||
│ └── __main__.py
|
│ └── __main__.py
|
||||||
├── app/ # Streamlit UI — works with any TEI study
|
├── app/ # Streamlit UI — works with any TEI study
|
||||||
│ ├── main.py # entry point
|
│ ├── main.py # entry point
|
||||||
│ ├── pages/ # benefits, costs, summary, versions
|
│ ├── 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, use_container_width=True)
|
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, use_container_width=True)
|
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, use_container_width=True)
|
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, use_container_width=True)
|
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, use_container_width=True)
|
st.plotly_chart(fig, width="stretch", key=key)
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ from __future__ import annotations
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
|
from app.locale import currency_fmt, fmt_currency, fmt_pct, pct_fmt, _STANDARD_LOCALE
|
||||||
|
|
||||||
|
|
||||||
def _years_for_table(fields: list[dict], analysis_years: int) -> list[int]:
|
def _years_for_table(fields: list[dict], analysis_years: int) -> list[int]:
|
||||||
"""Years 1..N — taken from analysis_period_years on the report."""
|
"""Years 1..N -- taken from analysis_period_years on the report."""
|
||||||
return list(range(1, max(int(analysis_years or 3), 1) + 1))
|
return list(range(1, max(int(analysis_years or 3), 1) + 1))
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +28,11 @@ def value_editor(
|
|||||||
columns, an ``initial`` column for costs, a risk_adjustment column, and
|
columns, an ``initial`` column for costs, a risk_adjustment column, and
|
||||||
a notes column. Returns the edited DataFrame; the caller is responsible
|
a notes column. Returns the edited DataFrame; the caller is responsible
|
||||||
for converting it back to value-row dicts and PUTting to Athena.
|
for converting it back to value-row dicts and PUTting to Athena.
|
||||||
|
|
||||||
|
Currency columns use the locale configured via PALLADIUM_CURRENCY_SYMBOL /
|
||||||
|
PALLADIUM_THOUSANDS_SEP / PALLADIUM_DECIMAL_SEP in .env.
|
||||||
|
The risk_adj column is stored as a 0-1 fraction and displayed as a
|
||||||
|
percentage (e.g. 0.20 -> "20.00%").
|
||||||
"""
|
"""
|
||||||
fields = [
|
fields = [
|
||||||
f
|
f
|
||||||
@@ -44,43 +51,76 @@ def value_editor(
|
|||||||
for f in fields:
|
for f in fields:
|
||||||
v = by_key.get(f["field_key"], {}) or {}
|
v = by_key.get(f["field_key"], {}) or {}
|
||||||
yv = v.get("year_values") or {}
|
yv = v.get("year_values") or {}
|
||||||
|
risk_raw = float(v.get("risk_adjustment") or 0.0)
|
||||||
row = {
|
row = {
|
||||||
"field_key": f["field_key"],
|
"field_key": f["field_key"],
|
||||||
"label": f.get("label", f["field_key"]),
|
"label": f.get("label", f["field_key"]),
|
||||||
"category": f.get("category", "") or "",
|
"category": f.get("category", "") or "",
|
||||||
}
|
}
|
||||||
if table == "costs":
|
if table == "costs":
|
||||||
row["Initial"] = float(v.get("initial") or 0.0)
|
if _STANDARD_LOCALE:
|
||||||
|
row["Initial"] = float(v.get("initial") or 0.0)
|
||||||
|
else:
|
||||||
|
row["Initial"] = fmt_currency(float(v.get("initial") or 0.0))
|
||||||
for y in years:
|
for y in years:
|
||||||
row[f"Year {y}"] = float(yv.get(str(y)) or 0.0)
|
raw = float(yv.get(str(y)) or 0.0)
|
||||||
row["risk_adj"] = float(v.get("risk_adjustment") or 0.0)
|
if _STANDARD_LOCALE:
|
||||||
|
row[f"Year {y}"] = raw
|
||||||
|
else:
|
||||||
|
row[f"Year {y}"] = fmt_currency(raw)
|
||||||
|
# Risk adj: store as fraction for standard locales (NumberColumn handles
|
||||||
|
# display), or pre-format as "20.00%" string for non-standard locales.
|
||||||
|
if _STANDARD_LOCALE:
|
||||||
|
row["risk_adj"] = risk_raw
|
||||||
|
else:
|
||||||
|
row["risk_adj"] = fmt_pct(risk_raw)
|
||||||
row["notes"] = v.get("notes", "") or ""
|
row["notes"] = v.get("notes", "") or ""
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
|
|
||||||
df = pd.DataFrame(rows)
|
df = pd.DataFrame(rows)
|
||||||
|
|
||||||
|
_cur_fmt = currency_fmt()
|
||||||
|
_pct_fmt_str = pct_fmt()
|
||||||
|
|
||||||
column_config: dict = {
|
column_config: dict = {
|
||||||
"field_key": st.column_config.TextColumn("Key", disabled=True, width="small"),
|
"field_key": st.column_config.TextColumn("Key", disabled=True, width="small"),
|
||||||
"label": st.column_config.TextColumn("Field", disabled=True),
|
"label": st.column_config.TextColumn("Field", disabled=True),
|
||||||
"category": st.column_config.TextColumn("Category", disabled=True, width="small"),
|
"category": st.column_config.TextColumn("Category", disabled=True, width="small"),
|
||||||
"risk_adj": st.column_config.NumberColumn(
|
|
||||||
"Risk Adj.", min_value=0.0, max_value=1.0, step=0.05, format="%.2f"
|
|
||||||
),
|
|
||||||
"notes": st.column_config.TextColumn("Notes", width="medium"),
|
"notes": st.column_config.TextColumn("Notes", width="medium"),
|
||||||
}
|
}
|
||||||
if table == "costs":
|
|
||||||
column_config["Initial"] = st.column_config.NumberColumn(
|
if _STANDARD_LOCALE:
|
||||||
"Initial", format="$%.0f"
|
column_config["risk_adj"] = st.column_config.NumberColumn(
|
||||||
|
"Risk Adj.",
|
||||||
|
min_value=0.0,
|
||||||
|
max_value=1.0,
|
||||||
|
step=0.05,
|
||||||
|
format=_pct_fmt_str,
|
||||||
|
help="Enter as a decimal fraction (e.g. 0.20 = 20%)",
|
||||||
)
|
)
|
||||||
for y in years:
|
if table == "costs":
|
||||||
column_config[f"Year {y}"] = st.column_config.NumberColumn(
|
column_config["Initial"] = st.column_config.NumberColumn(
|
||||||
f"Year {y}", format="$%.0f"
|
"Initial", format=_cur_fmt
|
||||||
|
)
|
||||||
|
for y in years:
|
||||||
|
column_config[f"Year {y}"] = st.column_config.NumberColumn(
|
||||||
|
f"Year {y}", format=_cur_fmt
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Non-standard locale: display as pre-formatted strings (read-only display;
|
||||||
|
# user edits the raw number and we re-format on save).
|
||||||
|
column_config["risk_adj"] = st.column_config.TextColumn(
|
||||||
|
"Risk Adj.", help="Displayed as percentage; stored as 0-1 fraction"
|
||||||
)
|
)
|
||||||
|
if table == "costs":
|
||||||
|
column_config["Initial"] = st.column_config.TextColumn("Initial")
|
||||||
|
for y in years:
|
||||||
|
column_config[f"Year {y}"] = st.column_config.TextColumn(f"Year {y}")
|
||||||
|
|
||||||
edited = st.data_editor(
|
edited = st.data_editor(
|
||||||
df,
|
df,
|
||||||
column_config=column_config,
|
column_config=column_config,
|
||||||
use_container_width=True,
|
width="stretch",
|
||||||
num_rows="fixed",
|
num_rows="fixed",
|
||||||
hide_index=True,
|
hide_index=True,
|
||||||
key=key,
|
key=key,
|
||||||
|
|||||||
87
app/locale.py
Normal file
87
app/locale.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""
|
||||||
|
Locale / formatting settings for the Palladium Streamlit app.
|
||||||
|
|
||||||
|
All settings are read from environment variables (via .env) so the same
|
||||||
|
codebase can be deployed for different regions without code changes.
|
||||||
|
|
||||||
|
Environment variables
|
||||||
|
---------------------
|
||||||
|
PALLADIUM_CURRENCY_SYMBOL Default: "$"
|
||||||
|
Prefix shown before monetary values (e.g. "$", "€", "£", "CAD ").
|
||||||
|
|
||||||
|
PALLADIUM_THOUSANDS_SEP Default: ","
|
||||||
|
Thousands separator used in number display (e.g. "," for Americas,
|
||||||
|
"." for continental Europe, " " for some locales).
|
||||||
|
|
||||||
|
PALLADIUM_DECIMAL_SEP Default: "."
|
||||||
|
Decimal separator (e.g. "." for Americas/UK, "," for continental Europe).
|
||||||
|
|
||||||
|
Note: Streamlit's NumberColumn ``format`` uses printf-style strings.
|
||||||
|
The ``%,`` flag (thousands separator) is supported in Streamlit ≥ 1.31.
|
||||||
|
For non-standard separators (e.g. European "." thousands / "," decimal)
|
||||||
|
the values are pre-formatted as strings and displayed in TextColumns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _env(key: str, default: str) -> str:
|
||||||
|
return os.environ.get(key, default).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Resolved settings (read once at import time; restart app to pick up changes)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
CURRENCY_SYMBOL: str = _env("PALLADIUM_CURRENCY_SYMBOL", "$")
|
||||||
|
THOUSANDS_SEP: str = _env("PALLADIUM_THOUSANDS_SEP", ",")
|
||||||
|
DECIMAL_SEP: str = _env("PALLADIUM_DECIMAL_SEP", ".")
|
||||||
|
|
||||||
|
# True when the locale uses standard printf-compatible separators
|
||||||
|
# (i.e. "," thousands + "." decimal — the C/POSIX default).
|
||||||
|
# When False, we pre-format values as strings instead of relying on printf.
|
||||||
|
_STANDARD_LOCALE: bool = THOUSANDS_SEP == "," and DECIMAL_SEP == "."
|
||||||
|
|
||||||
|
|
||||||
|
def currency_fmt() -> str:
|
||||||
|
"""Return a Streamlit NumberColumn ``format`` string for currency.
|
||||||
|
|
||||||
|
For standard locales returns e.g. ``"$%,.0f"`` (thousands-separated,
|
||||||
|
no decimal places). For non-standard locales returns ``"%s"`` and
|
||||||
|
callers should use :func:`fmt_currency` to pre-format the value.
|
||||||
|
"""
|
||||||
|
if _STANDARD_LOCALE:
|
||||||
|
return f"{CURRENCY_SYMBOL}%,.0f"
|
||||||
|
return "%s"
|
||||||
|
|
||||||
|
|
||||||
|
def pct_fmt() -> str:
|
||||||
|
"""Return a Streamlit NumberColumn ``format`` string for percentages.
|
||||||
|
|
||||||
|
Stores the value as a fraction (0–1) and displays as e.g. ``"20.00%"``.
|
||||||
|
Streamlit's ``%%`` in format strings renders a literal ``%``.
|
||||||
|
"""
|
||||||
|
if _STANDARD_LOCALE:
|
||||||
|
return "%.2f%%"
|
||||||
|
return "%s"
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_currency(value: float) -> str:
|
||||||
|
"""Format *value* as a currency string using the configured locale."""
|
||||||
|
if _STANDARD_LOCALE:
|
||||||
|
return f"{CURRENCY_SYMBOL}{value:,.0f}"
|
||||||
|
# Non-standard: build manually
|
||||||
|
integer_part = f"{int(abs(value)):,}".replace(",", THOUSANDS_SEP)
|
||||||
|
sign = "-" if value < 0 else ""
|
||||||
|
return f"{sign}{CURRENCY_SYMBOL}{integer_part}"
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_pct(value: float) -> str:
|
||||||
|
"""Format *value* (0–1 fraction) as a percentage string."""
|
||||||
|
pct = value * 100
|
||||||
|
if _STANDARD_LOCALE:
|
||||||
|
return f"{pct:.2f}%"
|
||||||
|
integer_part = f"{int(pct)}"
|
||||||
|
decimal_part = f"{abs(pct) % 1:.2f}"[1:] # ".xx"
|
||||||
|
return f"{integer_part}{DECIMAL_SEP}{decimal_part[1:]}%"
|
||||||
34
app/main.py
34
app/main.py
@@ -24,6 +24,7 @@ if str(_ROOT) not in sys.path:
|
|||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from core.tei_client import AthenaAPIError, TEIClient
|
from core.tei_client import AthenaAPIError, TEIClient
|
||||||
|
from app.utils import icon, inject_icons
|
||||||
|
|
||||||
st.set_page_config(
|
st.set_page_config(
|
||||||
page_title="Palladium — TEI Calculator",
|
page_title="Palladium — TEI Calculator",
|
||||||
@@ -32,6 +33,7 @@ st.set_page_config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@st.cache_resource(show_spinner=False)
|
@st.cache_resource(show_spinner=False)
|
||||||
def get_client() -> TEIClient:
|
def get_client() -> TEIClient:
|
||||||
return TEIClient()
|
return TEIClient()
|
||||||
@@ -75,7 +77,9 @@ def _crm_engagements(_client: TEIClient, client_name: str) -> list[dict]:
|
|||||||
|
|
||||||
def sidebar_tool_picker(client: TEIClient) -> dict | None:
|
def sidebar_tool_picker(client: TEIClient) -> dict | None:
|
||||||
"""Sidebar: pick an existing TEI tool or create one from a report template."""
|
"""Sidebar: pick an existing TEI tool or create one from a report template."""
|
||||||
st.sidebar.title("🛡️ Palladium")
|
st.sidebar.markdown(
|
||||||
|
f"{icon('shield-fill')} **Palladium**", unsafe_allow_html=True
|
||||||
|
)
|
||||||
st.sidebar.caption("TEI Calculator")
|
st.sidebar.caption("TEI Calculator")
|
||||||
|
|
||||||
tools = _safe_call(client.list_tools) or []
|
tools = _safe_call(client.list_tools) or []
|
||||||
@@ -163,15 +167,20 @@ def sidebar_tool_picker(client: TEIClient) -> dict | None:
|
|||||||
st.sidebar.markdown(f"**Public ID**: `{tool.get('id')}`")
|
st.sidebar.markdown(f"**Public ID**: `{tool.get('id')}`")
|
||||||
st.sidebar.markdown(f"**Status**: {tool.get('status', '?')}")
|
st.sidebar.markdown(f"**Status**: {tool.get('status', '?')}")
|
||||||
st.sidebar.markdown(f"**Version**: {tool.get('current_version', 0)}")
|
st.sidebar.markdown(f"**Version**: {tool.get('current_version', 0)}")
|
||||||
if st.sidebar.button("🔄 Recalculate"):
|
if st.sidebar.button("Recalculate"):
|
||||||
_safe_call(client.calculate, tool["id"])
|
_safe_call(client.calculate, tool["id"])
|
||||||
st.toast("Recalculated.", icon="✅")
|
st.toast("Recalculated.", icon=None)
|
||||||
st.cache_data.clear()
|
st.cache_data.clear()
|
||||||
return tool
|
return tool
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
st.title("Palladium — TEI Calculator")
|
inject_icons()
|
||||||
|
|
||||||
|
st.markdown(
|
||||||
|
f"<h1 style='margin-bottom:0'>{icon('shield-fill')} Palladium — TEI Calculator</h1>",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
client = get_client()
|
client = get_client()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -186,14 +195,19 @@ def main() -> None:
|
|||||||
st.info("Pick or create a TEI tool from the sidebar to begin.")
|
st.info("Pick or create a TEI tool from the sidebar to begin.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Tab navigation — matches `app/pages/*` modules but kept as tabs so all
|
# Tab navigation — matches `app/views/*` modules but kept as tabs so all
|
||||||
# views share the chosen tool/state without re-querying.
|
# views share the chosen tool/state without re-querying.
|
||||||
tabs = st.tabs(["📊 Summary", "💰 Benefits", "💸 Costs", "🕒 Versions"])
|
#
|
||||||
|
# NOTE: the directory is `app/views/`, NOT `app/pages/`. Streamlit treats a
|
||||||
|
# `pages/` directory next to the entrypoint as auto-discovered multipage
|
||||||
|
# scripts, which would render blank since these modules only define
|
||||||
|
# `render()` and have no top-level output.
|
||||||
|
tabs = st.tabs(["Summary", "Benefits", "Costs", "Versions"])
|
||||||
|
|
||||||
from app.pages import benefits as benefits_page
|
from app.views import benefits as benefits_page
|
||||||
from app.pages import costs as costs_page
|
from app.views import costs as costs_page
|
||||||
from app.pages import summary as summary_page
|
from app.views import summary as summary_page
|
||||||
from app.pages import versions as versions_page
|
from app.views import versions as versions_page
|
||||||
|
|
||||||
with tabs[0]:
|
with tabs[0]:
|
||||||
summary_page.render(client, tool)
|
summary_page.render(client, tool)
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
"""Financial summary dashboard tab."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import streamlit as st
|
|
||||||
|
|
||||||
from app.components import charts
|
|
||||||
from app.pages._helpers import report_meta, safe
|
|
||||||
from core.export import build_report_data
|
|
||||||
from core.tei_client import AthenaAPIError, TEIClient
|
|
||||||
|
|
||||||
|
|
||||||
def render(client: TEIClient, tool: dict) -> None:
|
|
||||||
st.header("📊 Financial Summary")
|
|
||||||
public_id = tool["id"]
|
|
||||||
report = report_meta(client, tool)
|
|
||||||
|
|
||||||
try:
|
|
||||||
summary = client.get_summary(public_id)
|
|
||||||
except AthenaAPIError as e:
|
|
||||||
if e.status_code == 404:
|
|
||||||
st.info(
|
|
||||||
"No summary yet — click **Recalculate** in the sidebar after "
|
|
||||||
"filling in benefits and costs."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
st.error(f"Athena API error: {e.detail}")
|
|
||||||
return
|
|
||||||
|
|
||||||
npv = float(summary.get("net_present_value") or summary.get("npv") or 0)
|
|
||||||
roi = float(
|
|
||||||
summary.get("roi_percentage")
|
|
||||||
or summary.get("roi")
|
|
||||||
or summary.get("roi_pct")
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
payback = summary.get("payback_period_months", summary.get("payback_months"))
|
|
||||||
bpv = float(summary.get("total_benefits_pv") or 0)
|
|
||||||
cpv = float(summary.get("total_costs_pv") or 0)
|
|
||||||
|
|
||||||
cols = st.columns(5)
|
|
||||||
cols[0].metric("NPV", f"${npv/1_000_000:,.1f}M")
|
|
||||||
cols[1].metric("ROI", f"{roi:,.0f}%")
|
|
||||||
cols[2].metric(
|
|
||||||
"Payback",
|
|
||||||
f"{float(payback):.1f} months" if payback is not None else "N/A",
|
|
||||||
)
|
|
||||||
cols[3].metric("Benefits PV", f"${bpv/1_000_000:,.1f}M")
|
|
||||||
cols[4].metric("Costs PV", f"${cpv/1_000_000:,.1f}M")
|
|
||||||
|
|
||||||
st.divider()
|
|
||||||
|
|
||||||
# Build the yearly breakdown from the documented per-year summary keys
|
|
||||||
# (benefits_year_N / costs_year_N) when no pre-built breakdown exists.
|
|
||||||
yb = summary.get("yearly_breakdown") or []
|
|
||||||
if not yb:
|
|
||||||
n = 1
|
|
||||||
while f"benefits_year_{n}" in summary or f"costs_year_{n}" in summary:
|
|
||||||
b = float(summary.get(f"benefits_year_{n}") or 0)
|
|
||||||
c = float(summary.get(f"costs_year_{n}") or 0)
|
|
||||||
yb.append({"year": n, "benefits": b, "costs": c, "net": b - c})
|
|
||||||
n += 1
|
|
||||||
initial = float(summary.get("initial_costs") or 0)
|
|
||||||
if yb:
|
|
||||||
charts.cashflow(yb, initial_cost=initial)
|
|
||||||
with st.expander("Cash flow table"):
|
|
||||||
st.dataframe(yb, use_container_width=True, hide_index=True)
|
|
||||||
else:
|
|
||||||
st.caption("No yearly breakdown in this summary.")
|
|
||||||
|
|
||||||
# Scenario comparison — computed locally from current values
|
|
||||||
with st.expander("Scenario analysis (conservative / moderate / aggressive)"):
|
|
||||||
envelope = safe(
|
|
||||||
build_report_data,
|
|
||||||
client,
|
|
||||||
public_id,
|
|
||||||
include_scenarios=True,
|
|
||||||
study_slug=report.get("name", ""),
|
|
||||||
)
|
|
||||||
if envelope and envelope.get("scenarios"):
|
|
||||||
charts.scenario_bars(envelope["scenarios"])
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
"Scenario": k,
|
|
||||||
"Benefits PV": float(v.get("total_benefits_pv") or 0),
|
|
||||||
"Costs PV": float(v.get("total_costs_pv") or 0),
|
|
||||||
"NPV": float(v.get("npv") or 0),
|
|
||||||
"ROI %": float(v.get("roi_pct") or 0),
|
|
||||||
"Payback (months)": (
|
|
||||||
round(float(v.get("payback_months") or 0), 1)
|
|
||||||
if v.get("payback_months") is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
for k, v in envelope["scenarios"].items()
|
|
||||||
]
|
|
||||||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
|
||||||
|
|
||||||
# Export button
|
|
||||||
st.divider()
|
|
||||||
if st.button("📦 Build export envelope (JSON)"):
|
|
||||||
envelope = safe(
|
|
||||||
build_report_data,
|
|
||||||
client,
|
|
||||||
public_id,
|
|
||||||
include_scenarios=True,
|
|
||||||
study_slug=report.get("name", ""),
|
|
||||||
)
|
|
||||||
if envelope:
|
|
||||||
import json
|
|
||||||
|
|
||||||
data = json.dumps(envelope, indent=2, default=str)
|
|
||||||
st.download_button(
|
|
||||||
"Download export.json",
|
|
||||||
data=data,
|
|
||||||
file_name=f"{public_id}_export.json",
|
|
||||||
mime="application/json",
|
|
||||||
)
|
|
||||||
45
app/utils.py
Normal file
45
app/utils.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
Shared UI utilities for the Palladium Streamlit app.
|
||||||
|
|
||||||
|
Kept in a separate module so that ``app.main`` and ``app.views.*`` can both
|
||||||
|
import from here without creating a circular dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bootstrap Icons — injected once at the top of every page render.
|
||||||
|
# Using the CDN stylesheet so no npm/build step is needed.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_BI_CSS = """
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
/* Tighten up the default Streamlit header spacing */
|
||||||
|
.block-container { padding-top: 1.5rem; }
|
||||||
|
/* Make BI icons align nicely with surrounding text */
|
||||||
|
.bi { vertical-align: -0.125em; }
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def inject_icons() -> None:
|
||||||
|
"""Inject Bootstrap Icons CSS (idempotent — Streamlit deduplicates identical HTML)."""
|
||||||
|
st.markdown(_BI_CSS, unsafe_allow_html=True)
|
||||||
|
|
||||||
|
|
||||||
|
def icon(name: str, *, cls: str = "") -> str:
|
||||||
|
"""Return an inline Bootstrap Icon ``<i>`` tag.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
st.markdown(icon("bar-chart") + " Financial Summary", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
See the full icon catalogue at https://icons.getbootstrap.com/
|
||||||
|
"""
|
||||||
|
extra = f" {cls}" if cls else ""
|
||||||
|
return f'<i class="bi bi-{name}{extra}"></i>'
|
||||||
@@ -4,13 +4,19 @@ 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.pages._helpers import report_meta, safe
|
from app.utils import icon
|
||||||
|
|
||||||
|
from app.views._helpers import report_meta, safe
|
||||||
from core.tei_client import TEIClient
|
from core.tei_client import TEIClient
|
||||||
|
|
||||||
|
|
||||||
def render(client: TEIClient, tool: dict) -> None:
|
def render(client: TEIClient, tool: dict) -> None:
|
||||||
st.header("💰 Benefits")
|
st.markdown(
|
||||||
|
f"<h2>{icon('graph-up-arrow')} Benefits</h2>",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
public_id = tool["id"]
|
public_id = tool["id"]
|
||||||
report = report_meta(client, tool)
|
report = report_meta(client, tool)
|
||||||
analysis_years = int(report.get("analysis_period_years") or 3)
|
analysis_years = int(report.get("analysis_period_years") or 3)
|
||||||
@@ -32,7 +38,8 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
|
|
||||||
col1, col2 = st.columns([1, 4])
|
col1, col2 = st.columns([1, 4])
|
||||||
with col1:
|
with col1:
|
||||||
if st.button("💾 Save benefits", use_container_width=True):
|
if st.button("Save benefits", width="stretch"):
|
||||||
|
|
||||||
payload = df_to_values(edited, "benefits", analysis_years)
|
payload = df_to_values(edited, "benefits", analysis_years)
|
||||||
result = safe(client.update_values, public_id, payload)
|
result = safe(client.update_values, public_id, payload)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@@ -44,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,13 +4,19 @@ 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.pages._helpers import report_meta, safe
|
from app.utils import icon
|
||||||
|
|
||||||
|
from app.views._helpers import report_meta, safe
|
||||||
from core.tei_client import TEIClient
|
from core.tei_client import TEIClient
|
||||||
|
|
||||||
|
|
||||||
def render(client: TEIClient, tool: dict) -> None:
|
def render(client: TEIClient, tool: dict) -> None:
|
||||||
st.header("💸 Costs")
|
st.markdown(
|
||||||
|
f"<h2>{icon('receipt')} Costs</h2>",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
public_id = tool["id"]
|
public_id = tool["id"]
|
||||||
report = report_meta(client, tool)
|
report = report_meta(client, tool)
|
||||||
analysis_years = int(report.get("analysis_period_years") or 3)
|
analysis_years = int(report.get("analysis_period_years") or 3)
|
||||||
@@ -32,7 +38,8 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
|
|
||||||
col1, col2 = st.columns([1, 4])
|
col1, col2 = st.columns([1, 4])
|
||||||
with col1:
|
with col1:
|
||||||
if st.button("💾 Save costs", use_container_width=True):
|
if st.button("Save costs", width="stretch"):
|
||||||
|
|
||||||
payload = df_to_values(edited, "costs", analysis_years)
|
payload = df_to_values(edited, "costs", analysis_years)
|
||||||
result = safe(client.update_values, public_id, payload)
|
result = safe(client.update_values, public_id, payload)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@@ -44,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}"
|
||||||
|
)
|
||||||
202
app/views/summary.py
Normal file
202
app/views/summary.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""Financial summary dashboard tab."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
from app.components import charts
|
||||||
|
from app.locale import CURRENCY_SYMBOL, currency_fmt, fmt_currency
|
||||||
|
from app.utils import icon
|
||||||
|
from app.views._helpers import report_meta, safe
|
||||||
|
|
||||||
|
from core.export import build_report_data
|
||||||
|
from core.tei_client import AthenaAPIError, TEIClient
|
||||||
|
|
||||||
|
|
||||||
|
def render(client: TEIClient, tool: dict) -> None:
|
||||||
|
st.markdown(
|
||||||
|
f"<h2>{icon('bar-chart-line')} Financial Summary</h2>",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
public_id = tool["id"]
|
||||||
|
report = report_meta(client, tool)
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = client.get_summary(public_id)
|
||||||
|
except AthenaAPIError as e:
|
||||||
|
if e.status_code == 404:
|
||||||
|
st.info(
|
||||||
|
"No summary yet — click **Recalculate** in the sidebar after "
|
||||||
|
"filling in benefits and costs."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
st.error(f"Athena API error: {e.detail}")
|
||||||
|
return
|
||||||
|
|
||||||
|
npv = float(summary.get("net_present_value") or summary.get("npv") or 0)
|
||||||
|
roi = float(
|
||||||
|
summary.get("roi_percentage")
|
||||||
|
or summary.get("roi")
|
||||||
|
or summary.get("roi_pct")
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
payback = summary.get("payback_period_months", summary.get("payback_months"))
|
||||||
|
bpv = float(summary.get("total_benefits_pv") or 0)
|
||||||
|
cpv = float(summary.get("total_costs_pv") or 0)
|
||||||
|
|
||||||
|
cols = st.columns(5)
|
||||||
|
cols[0].metric("NPV", f"{CURRENCY_SYMBOL}{npv/1_000_000:,.1f}M")
|
||||||
|
cols[1].metric("ROI", f"{roi:,.0f}%")
|
||||||
|
cols[2].metric(
|
||||||
|
"Payback",
|
||||||
|
f"{float(payback):.1f} months" if payback is not None else "N/A",
|
||||||
|
)
|
||||||
|
cols[3].metric("Benefits PV", f"{CURRENCY_SYMBOL}{bpv/1_000_000:,.1f}M")
|
||||||
|
cols[4].metric("Costs PV", f"{CURRENCY_SYMBOL}{cpv/1_000_000:,.1f}M")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# ── Financial visualizations ────────────────────────────────────
|
||||||
|
# Built from the live value rows so Year-0 "Initial" amounts stay
|
||||||
|
# separate (Athena's per-year summary folds them into Year 1).
|
||||||
|
values = safe(client.get_values, public_id) or []
|
||||||
|
benefit_rows = [v for v in values if v.get("table") == "benefits"]
|
||||||
|
cost_rows = [v for v in values if v.get("table") == "costs"]
|
||||||
|
|
||||||
|
if benefit_rows or cost_rows:
|
||||||
|
col_pie, col_bar = st.columns(2)
|
||||||
|
with col_pie:
|
||||||
|
charts.cost_pie(cost_rows, key=f"summary_pie_{public_id}")
|
||||||
|
with col_bar:
|
||||||
|
charts.benefits_bar(benefit_rows, key=f"summary_bar_{public_id}")
|
||||||
|
charts.benefits_vs_costs_by_year(
|
||||||
|
benefit_rows, cost_rows, key=f"summary_by_year_{public_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cash flow + cumulative net — the Forrester-style exhibit.
|
||||||
|
def _yearly_breakdown_from_values():
|
||||||
|
initial = sum(float(c.get("initial") or 0) for c in cost_rows)
|
||||||
|
years: set[int] = set()
|
||||||
|
for v in [*benefit_rows, *cost_rows]:
|
||||||
|
years.update(int(y) for y in (v.get("year_values") or {}))
|
||||||
|
rows, cumulative = [], -initial
|
||||||
|
for y in sorted(years):
|
||||||
|
b = sum(
|
||||||
|
float((v.get("year_values") or {}).get(str(y), 0) or 0)
|
||||||
|
* (1 - float(v.get("risk_adjustment") or 0))
|
||||||
|
for v in benefit_rows
|
||||||
|
)
|
||||||
|
c = sum(
|
||||||
|
float((v.get("year_values") or {}).get(str(y), 0) or 0)
|
||||||
|
for v in cost_rows
|
||||||
|
)
|
||||||
|
cumulative += b - c
|
||||||
|
rows.append(
|
||||||
|
{"year": y, "benefits": b, "costs": c, "net": b - c,
|
||||||
|
"cumulative_net": cumulative}
|
||||||
|
)
|
||||||
|
return rows, initial
|
||||||
|
|
||||||
|
yb, initial = ([], 0.0)
|
||||||
|
if benefit_rows or cost_rows:
|
||||||
|
yb, initial = _yearly_breakdown_from_values()
|
||||||
|
if not yb:
|
||||||
|
# Fallback: documented per-year summary keys (initial folded in Y1).
|
||||||
|
n = 1
|
||||||
|
while f"benefits_year_{n}" in summary or f"costs_year_{n}" in summary:
|
||||||
|
b = float(summary.get(f"benefits_year_{n}") or 0)
|
||||||
|
c = float(summary.get(f"costs_year_{n}") or 0)
|
||||||
|
yb.append({"year": n, "benefits": b, "costs": c, "net": b - c})
|
||||||
|
n += 1
|
||||||
|
initial = float(summary.get("initial_costs") or 0)
|
||||||
|
if yb:
|
||||||
|
charts.cashflow(yb, initial_cost=initial, key=f"summary_cashflow_{public_id}")
|
||||||
|
with st.expander("Cash flow table"):
|
||||||
|
_cur = currency_fmt()
|
||||||
|
st.dataframe(
|
||||||
|
yb,
|
||||||
|
column_config={
|
||||||
|
"year": st.column_config.NumberColumn("Year", format="%d"),
|
||||||
|
"benefits": st.column_config.NumberColumn("Benefits", format=_cur),
|
||||||
|
"costs": st.column_config.NumberColumn("Costs", format=_cur),
|
||||||
|
"net": st.column_config.NumberColumn("Net", format=_cur),
|
||||||
|
},
|
||||||
|
width="stretch",
|
||||||
|
hide_index=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.caption("No yearly breakdown in this summary.")
|
||||||
|
|
||||||
|
# Waterfall — Benefits PV down to NPV.
|
||||||
|
if bpv or cpv:
|
||||||
|
charts.waterfall([
|
||||||
|
("Benefits PV", bpv),
|
||||||
|
("Costs PV", -cpv),
|
||||||
|
("NPV", npv),
|
||||||
|
], key=f"summary_waterfall_{public_id}")
|
||||||
|
|
||||||
|
# Scenario comparison — computed locally from current values
|
||||||
|
with st.expander("Scenario analysis (conservative / moderate / aggressive)"):
|
||||||
|
envelope = safe(
|
||||||
|
build_report_data,
|
||||||
|
client,
|
||||||
|
public_id,
|
||||||
|
include_scenarios=True,
|
||||||
|
study_slug=report.get("name", ""),
|
||||||
|
)
|
||||||
|
if envelope and envelope.get("scenarios"):
|
||||||
|
charts.scenario_bars(
|
||||||
|
envelope["scenarios"], key=f"summary_scenarios_{public_id}"
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"Scenario": k,
|
||||||
|
"Benefits PV": float(v.get("total_benefits_pv") or 0),
|
||||||
|
"Costs PV": float(v.get("total_costs_pv") or 0),
|
||||||
|
"NPV": float(v.get("npv") or 0),
|
||||||
|
"ROI %": float(v.get("roi_pct") or 0),
|
||||||
|
"Payback (months)": (
|
||||||
|
round(float(v.get("payback_months") or 0), 1)
|
||||||
|
if v.get("payback_months") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for k, v in envelope["scenarios"].items()
|
||||||
|
]
|
||||||
|
_cur = currency_fmt()
|
||||||
|
st.dataframe(
|
||||||
|
rows,
|
||||||
|
column_config={
|
||||||
|
"Scenario": st.column_config.TextColumn("Scenario"),
|
||||||
|
"Benefits PV": st.column_config.NumberColumn("Benefits PV", format=_cur),
|
||||||
|
"Costs PV": st.column_config.NumberColumn("Costs PV", format=_cur),
|
||||||
|
"NPV": st.column_config.NumberColumn("NPV", format=_cur),
|
||||||
|
"ROI %": st.column_config.NumberColumn("ROI %", format="%.1f%%"),
|
||||||
|
"Payback (months)": st.column_config.NumberColumn(
|
||||||
|
"Payback (months)", format="%.1f"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
width="stretch",
|
||||||
|
hide_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Export button
|
||||||
|
st.divider()
|
||||||
|
if st.button("Build export envelope (JSON)"):
|
||||||
|
envelope = safe(
|
||||||
|
build_report_data,
|
||||||
|
client,
|
||||||
|
public_id,
|
||||||
|
include_scenarios=True,
|
||||||
|
study_slug=report.get("name", ""),
|
||||||
|
)
|
||||||
|
if envelope:
|
||||||
|
import json
|
||||||
|
|
||||||
|
data = json.dumps(envelope, indent=2, default=str)
|
||||||
|
st.download_button(
|
||||||
|
"Download export.json",
|
||||||
|
data=data,
|
||||||
|
file_name=f"{public_id}_export.json",
|
||||||
|
mime="application/json",
|
||||||
|
)
|
||||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from app.pages._helpers import safe
|
from app.utils import icon
|
||||||
|
|
||||||
|
from app.views._helpers import safe
|
||||||
from core.tei_client import TEIClient
|
from core.tei_client import TEIClient
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ def _diff_rows(a: dict[str, dict], b: dict[str, dict]) -> list[dict]:
|
|||||||
"""Return one row per field with side-by-side year values."""
|
"""Return one row per field with side-by-side year values."""
|
||||||
keys = sorted(set(a.keys()) | set(b.keys()))
|
keys = sorted(set(a.keys()) | set(b.keys()))
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
|
|
||||||
def _years_of(v: dict) -> dict:
|
def _years_of(v: dict) -> dict:
|
||||||
"""Accept both friendly (year_values) and wire (nested years) shapes."""
|
"""Accept both friendly (year_values) and wire (nested years) shapes."""
|
||||||
if isinstance(v.get("year_values"), dict):
|
if isinstance(v.get("year_values"), dict):
|
||||||
@@ -54,7 +57,10 @@ def _diff_rows(a: dict[str, dict], b: dict[str, dict]) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def render(client: TEIClient, tool: dict) -> None:
|
def render(client: TEIClient, tool: dict) -> None:
|
||||||
st.header("🕒 Versions")
|
st.markdown(
|
||||||
|
f"<h2>{icon('clock-history')} Versions</h2>",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
public_id = tool["id"]
|
public_id = tool["id"]
|
||||||
|
|
||||||
versions = safe(client.list_versions, public_id) or []
|
versions = safe(client.list_versions, public_id) or []
|
||||||
@@ -63,7 +69,7 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Save new version
|
# Save new version
|
||||||
with st.expander("➕ Save current state as a new version", expanded=not versions):
|
with st.expander("Save current state as a new version", expanded=not versions):
|
||||||
note = st.text_area(
|
note = st.text_area(
|
||||||
"Version note",
|
"Version note",
|
||||||
placeholder=(
|
placeholder=(
|
||||||
@@ -71,7 +77,7 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
"raised legacy license cost from $160 to $180/agent.'"
|
"raised legacy license cost from $160 to $180/agent.'"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if st.button("💾 Save version", disabled=not note.strip()):
|
if st.button("Save version", disabled=not note.strip()):
|
||||||
result = safe(client.save_version, public_id, note.strip())
|
result = safe(client.save_version, public_id, note.strip())
|
||||||
if result:
|
if result:
|
||||||
st.success(
|
st.success(
|
||||||
@@ -102,7 +108,8 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
"Note": v.get("note", ""),
|
"Note": v.get("note", ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
st.dataframe(rows, width="stretch", hide_index=True)
|
||||||
|
|
||||||
|
|
||||||
# Compare two versions
|
# Compare two versions
|
||||||
st.subheader("Compare")
|
st.subheader("Compare")
|
||||||
@@ -132,4 +139,5 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
if not diff:
|
if not diff:
|
||||||
st.success("No value differences between these versions.")
|
st.success("No value differences between these versions.")
|
||||||
else:
|
else:
|
||||||
st.dataframe(diff, use_container_width=True, hide_index=True)
|
st.dataframe(diff, width="stretch", hide_index=True)
|
||||||
|
|
||||||
@@ -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,239 @@
|
|||||||
|
"""Benefit engine."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tokencalc.benefit_model import (
|
||||||
|
calculate_acw_summarization_benefit,
|
||||||
|
calculate_email_ai_benefit,
|
||||||
|
calculate_total_benefit,
|
||||||
|
calculate_va_deflection_benefit,
|
||||||
|
)
|
||||||
|
from tokencalc.defaults import CTM_DEFAULT_FEATURE_SCOPES, CTM_DEFAULT_SITES
|
||||||
|
from tokencalc.inputs import WORKING_SECONDS_PER_YEAR, FeatureScope, SiteInput
|
||||||
|
from tokencalc.scenarios import BENEFIT_PARAMS
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ── Virtual Agent deflection tests ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_va_bot_deflection_hand_check():
|
||||||
|
"""Voice Bot: 10,000 calls/mo × 12 × 35% bot_rate × 300s AHT
|
||||||
|
× 50% Y1 realization × realization_factor × $0.01/s.
|
||||||
|
|
||||||
|
realistic realization_factor = 0.70 × 0.80 × (1 − 0.05) = 0.532
|
||||||
|
"""
|
||||||
|
site = _small_site()
|
||||||
|
df = calculate_va_deflection_benefit(
|
||||||
|
[site],
|
||||||
|
FeatureScope("Voice Bot", ["Small"], deflection_target=0.35),
|
||||||
|
"realistic",
|
||||||
|
year=1,
|
||||||
|
params="realistic",
|
||||||
|
)
|
||||||
|
completion = BENEFIT_PARAMS["va_completion_rate"]["realistic"]
|
||||||
|
labour = BENEFIT_PARAMS["va_labour_realization"]["realistic"]
|
||||||
|
callback = BENEFIT_PARAMS["va_callback_discount"]["realistic"]
|
||||||
|
real_factor = completion * labour * (1.0 - callback)
|
||||||
|
expected = (
|
||||||
|
10_000 * 12 # annual calls
|
||||||
|
* 0.35 # bot deflection rate
|
||||||
|
* 300 # AHT seconds
|
||||||
|
* 0.50 # Y1 scenario realization
|
||||||
|
* real_factor # completion × labour × (1 − callback)
|
||||||
|
* 0.01 # labour rate per second
|
||||||
|
)
|
||||||
|
assert df["annual_value"].sum() == pytest.approx(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_va_agentic_deflection_uses_residual():
|
||||||
|
"""Agentic VA must operate on the residual (1 − bot_rate) call pool,
|
||||||
|
not the full volume.
|
||||||
|
|
||||||
|
With bot_rate=0.35 and va_rate=0.15:
|
||||||
|
residual = 10,000 × (1 − 0.35) = 6,500 calls/mo
|
||||||
|
va_deflected = 6,500 × 0.15 = 975 calls/mo
|
||||||
|
"""
|
||||||
|
site = _small_site()
|
||||||
|
df = calculate_va_deflection_benefit(
|
||||||
|
[site],
|
||||||
|
FeatureScope("Agentic Virtual Agent", ["Small"], deflection_target=0.15),
|
||||||
|
"realistic",
|
||||||
|
year=1,
|
||||||
|
params="realistic",
|
||||||
|
)
|
||||||
|
completion = BENEFIT_PARAMS["va_completion_rate"]["realistic"]
|
||||||
|
labour = BENEFIT_PARAMS["va_labour_realization"]["realistic"]
|
||||||
|
callback = BENEFIT_PARAMS["va_callback_discount"]["realistic"]
|
||||||
|
real_factor = completion * labour * (1.0 - callback)
|
||||||
|
# realistic scenario: voice_bot_deflection = 0.35
|
||||||
|
bot_rate = 0.35
|
||||||
|
va_rate = 0.15
|
||||||
|
expected = (
|
||||||
|
10_000 * 12 # annual calls
|
||||||
|
* (1.0 - bot_rate) * va_rate # residual × va_rate (layered)
|
||||||
|
* 300 # AHT seconds
|
||||||
|
* 0.50 # Y1 scenario realization
|
||||||
|
* real_factor
|
||||||
|
* 0.01
|
||||||
|
)
|
||||||
|
assert df["annual_value"].sum() == pytest.approx(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_va_no_double_count():
|
||||||
|
"""Combined bot + VA benefit must be less than the naive additive sum.
|
||||||
|
|
||||||
|
Naive (wrong): volume × (bot_rate + va_rate) × AHT × ...
|
||||||
|
Correct (layered): volume × (bot_rate + (1−bot_rate)×va_rate) × AHT × ...
|
||||||
|
|
||||||
|
With bot=35%, va=15%:
|
||||||
|
naive total deflection = 50%
|
||||||
|
layered total deflection = 35% + 65%×15% = 44.75%
|
||||||
|
"""
|
||||||
|
site = _small_site()
|
||||||
|
bot_scope = FeatureScope("Voice Bot", ["Small"], deflection_target=0.35)
|
||||||
|
va_scope = FeatureScope("Agentic Virtual Agent", ["Small"], deflection_target=0.15)
|
||||||
|
|
||||||
|
bot_df = calculate_va_deflection_benefit([site], bot_scope, "realistic", year=1)
|
||||||
|
va_df = calculate_va_deflection_benefit([site], va_scope, "realistic", year=1)
|
||||||
|
combined = bot_df["annual_value"].sum() + va_df["annual_value"].sum()
|
||||||
|
|
||||||
|
# Naive additive (the old broken model): both on full volume
|
||||||
|
completion = BENEFIT_PARAMS["va_completion_rate"]["realistic"]
|
||||||
|
labour = BENEFIT_PARAMS["va_labour_realization"]["realistic"]
|
||||||
|
callback = BENEFIT_PARAMS["va_callback_discount"]["realistic"]
|
||||||
|
real_factor = completion * labour * (1.0 - callback)
|
||||||
|
naive = (
|
||||||
|
10_000 * 12 * (0.35 + 0.15) * 300 * 0.50 * real_factor * 0.01
|
||||||
|
)
|
||||||
|
assert combined < naive, (
|
||||||
|
f"Combined layered benefit ({combined:.2f}) should be less than "
|
||||||
|
f"naive additive ({naive:.2f}) — double-count not fixed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also verify the exact layered total
|
||||||
|
layered_deflection = 0.35 + (1.0 - 0.35) * 0.15 # = 0.4475
|
||||||
|
expected_combined = (
|
||||||
|
10_000 * 12 * layered_deflection * 300 * 0.50 * real_factor * 0.01
|
||||||
|
)
|
||||||
|
assert combined == pytest.approx(expected_combined)
|
||||||
|
|
||||||
|
|
||||||
|
def test_va_claim_params_reproduce_no_haircut():
|
||||||
|
"""params='claim' must apply zero haircuts (all factors = 1.0),
|
||||||
|
reproducing the original Genesys ROI-doc assumption."""
|
||||||
|
site = _small_site()
|
||||||
|
df_claim = calculate_va_deflection_benefit(
|
||||||
|
[site],
|
||||||
|
FeatureScope("Voice Bot", ["Small"], deflection_target=0.35),
|
||||||
|
"realistic",
|
||||||
|
year=1,
|
||||||
|
params="claim",
|
||||||
|
)
|
||||||
|
df_realistic = calculate_va_deflection_benefit(
|
||||||
|
[site],
|
||||||
|
FeatureScope("Voice Bot", ["Small"], deflection_target=0.35),
|
||||||
|
"realistic",
|
||||||
|
year=1,
|
||||||
|
params="realistic",
|
||||||
|
)
|
||||||
|
# claim should be strictly higher (no haircuts applied)
|
||||||
|
assert df_claim["annual_value"].sum() > df_realistic["annual_value"].sum()
|
||||||
|
|
||||||
|
# claim realization_factor = 1.0 × 1.0 × (1 − 0.0) = 1.0
|
||||||
|
expected_claim = 10_000 * 12 * 0.35 * 300 * 0.50 * 1.0 * 0.01
|
||||||
|
assert df_claim["annual_value"].sum() == pytest.approx(expected_claim)
|
||||||
@@ -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, _rollout = 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,144 @@
|
|||||||
|
"""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 [named]"),
|
||||||
|
DEFAULT_METERS["Speech & Text Analytics [named]"], 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 [named]"),
|
||||||
|
DEFAULT_METERS["Agent Copilot [named]"], DEFAULT_PRICING,
|
||||||
|
)
|
||||||
|
assert df["annual_cost"].sum() == pytest.approx(1_002_240)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_translate_not_active_before_phase():
|
||||||
|
"""AI Translate (consumption meter) produces zero cost before its phase."""
|
||||||
|
scenario = get_scenario("realistic")
|
||||||
|
apac_sites = [s.site_name for s in CTM_DEFAULT_SITES if s.region_pricing == "APAC"]
|
||||||
|
df = calculate_consumption_ai_cost(
|
||||||
|
CTM_DEFAULT_SITES,
|
||||||
|
_scope("AI Translate", apac_sites, phase=3),
|
||||||
|
DEFAULT_METERS["AI Translate"], scenario, 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 [named]"),
|
||||||
|
_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 [named]", [apac_site.site_name]),
|
||||||
|
DEFAULT_METERS["Speech & Text Analytics [named]"], 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,94 @@
|
|||||||
|
"""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
|
||||||
|
"Voice Bot", "Digital Bot",
|
||||||
|
# Virtual Agent
|
||||||
|
"Virtual Agent (legacy)", "Agentic Virtual Agent",
|
||||||
|
# Agent Copilot (named + concurrent)
|
||||||
|
"Agent Copilot [named]", "Agent Copilot [concurrent]",
|
||||||
|
# AI Quality / Analytics
|
||||||
|
"AI Scoring", "AI Summary & Insights",
|
||||||
|
# Speech & Text Analytics (named + concurrent)
|
||||||
|
"Speech & Text Analytics [named]", "Speech & Text Analytics [concurrent]",
|
||||||
|
# Routing
|
||||||
|
"Predictive Routing",
|
||||||
|
# Messaging
|
||||||
|
"Direct Messaging", "Social Listening", "Social Responses",
|
||||||
|
# Language
|
||||||
|
"AI Translate",
|
||||||
|
# Genesys Cloud Copilot
|
||||||
|
"Genesys Cloud Copilot",
|
||||||
|
# Email AI (rates TBD)
|
||||||
|
"Email AI (Auto-Suggest)", "Email AI (Auto-Respond)",
|
||||||
|
}
|
||||||
|
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["Digital Bot"].units_per_token == 51
|
||||||
|
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
|
||||||
|
# Named variants
|
||||||
|
assert m["Speech & Text Analytics [named]"].tokens_per_unit == 30
|
||||||
|
assert m["Speech & Text Analytics [concurrent]"].tokens_per_unit == 45
|
||||||
|
assert m["Agent Copilot [named]"].tokens_per_unit == 40
|
||||||
|
assert m["Agent Copilot [concurrent]"].tokens_per_unit == 60
|
||||||
|
# AI Translate is now a confirmed consumption meter
|
||||||
|
assert m["AI Translate"].tokens_per_unit == 0.5
|
||||||
|
assert m["AI Translate"].units_per_token == 2
|
||||||
|
assert m["AI Translate"].confidence is Confidence.CONFIRMED
|
||||||
|
# New meters
|
||||||
|
assert m["AI Scoring"].units_per_token == 20
|
||||||
|
assert m["Predictive Routing"].units_per_token == 17
|
||||||
|
assert m["Genesys Cloud Copilot"].units_per_token == 20
|
||||||
|
|
||||||
|
|
||||||
|
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)",
|
||||||
|
}
|
||||||
|
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,442 @@
|
|||||||
|
"""
|
||||||
|
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_va_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 or Agentic VA.
|
||||||
|
|
||||||
|
**Layered (sequential) deflection model** — Voice Bot runs first on
|
||||||
|
the full call pool; Agentic VA handles a share of the *residual*
|
||||||
|
(calls the bot did not deflect). The two mechanisms are substitutes
|
||||||
|
operating on the same call base, not independent additive benefits.
|
||||||
|
|
||||||
|
Effective total deflection:
|
||||||
|
bot_rate + (1 − bot_rate) × va_rate
|
||||||
|
e.g. 35% + 65% × 15% = 44.75% (not 50%)
|
||||||
|
|
||||||
|
**Three realization haircuts** are applied to convert raw deflected
|
||||||
|
volume into realizable labour savings:
|
||||||
|
|
||||||
|
1. ``completion_rate`` — share of "deflected" calls that don't
|
||||||
|
escalate to an agent mid-session (bot/VA fully handles the call).
|
||||||
|
2. ``labour_realization`` — staffing flexibility factor; deflected
|
||||||
|
volume doesn't reduce headcount 1:1 due to minimums, shrinkage,
|
||||||
|
and occupancy ceilings.
|
||||||
|
3. ``callback_discount`` — fraction of deflected calls that re-enter
|
||||||
|
as repeat contacts (poorly-handled deflections drive callbacks).
|
||||||
|
|
||||||
|
Combined realistic factor: 0.70 × 0.80 × (1 − 0.05) ≈ 0.53
|
||||||
|
|
||||||
|
The ``params="claim"`` path sets all three factors to their
|
||||||
|
``claim`` values (1.0 / 1.0 / 0.0) to reproduce the original
|
||||||
|
Genesys ROI-doc figures for side-by-side comparison.
|
||||||
|
"""
|
||||||
|
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||||
|
ro = rollout or NO_ROLLOUT
|
||||||
|
realization = sc.realization(year)
|
||||||
|
|
||||||
|
# Realization haircuts — read from BENEFIT_PARAMS so claim/realistic
|
||||||
|
# paths are consistent with all other benefit lines.
|
||||||
|
completion_rate = _param("va_completion_rate", params)
|
||||||
|
labour_real = _param("va_labour_realization", params)
|
||||||
|
callback_disc = _param("va_callback_discount", params)
|
||||||
|
realization_factor = completion_rate * labour_real * (1.0 - callback_disc)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for s in sites:
|
||||||
|
if not feature_scope.active(s.site_name, year):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if feature_scope.feature == "Voice Bot":
|
||||||
|
# Bot operates on the full call pool.
|
||||||
|
bot_rate = (
|
||||||
|
feature_scope.deflection_target
|
||||||
|
if feature_scope.deflection_target is not None
|
||||||
|
else sc.voice_bot_deflection
|
||||||
|
)
|
||||||
|
deflected_calls = s.voice_volume_monthly * MONTHS_PER_YEAR * bot_rate
|
||||||
|
|
||||||
|
else: # Agentic Virtual Agent
|
||||||
|
# VA operates on the residual after the bot has deflected its share.
|
||||||
|
# If Voice Bot is not in scope (VA-only deployment), bot_rate = 0
|
||||||
|
# and the VA works on the full pool — still correct.
|
||||||
|
bot_rate = sc.voice_bot_deflection
|
||||||
|
va_rate = (
|
||||||
|
feature_scope.deflection_target
|
||||||
|
if feature_scope.deflection_target is not None
|
||||||
|
else sc.agentic_va_deflection
|
||||||
|
)
|
||||||
|
residual_calls = (
|
||||||
|
s.voice_volume_monthly * MONTHS_PER_YEAR * (1.0 - bot_rate)
|
||||||
|
)
|
||||||
|
deflected_calls = residual_calls * va_rate
|
||||||
|
|
||||||
|
seconds_saved = deflected_calls * 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
|
||||||
|
* realization_factor
|
||||||
|
* 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.
|
||||||
|
#: Agent Copilot and STA exist in named/concurrent variants — both map
|
||||||
|
#: to the same benefit calculators.
|
||||||
|
#: Voice Bot and Agentic VA both route to calculate_va_deflection_benefit,
|
||||||
|
#: which implements the layered sequential model — VA operates on the
|
||||||
|
#: residual after the bot has deflected its share.
|
||||||
|
_BENEFIT_DISPATCH = {
|
||||||
|
"Agent Copilot [named]": (
|
||||||
|
calculate_voice_handle_time_benefit,
|
||||||
|
calculate_acw_summarization_benefit,
|
||||||
|
),
|
||||||
|
"Agent Copilot [concurrent]": (
|
||||||
|
calculate_voice_handle_time_benefit,
|
||||||
|
calculate_acw_summarization_benefit,
|
||||||
|
),
|
||||||
|
"AI Summary & Insights": (), # benefit carried by Copilot where present
|
||||||
|
"Speech & Text Analytics [named]": (calculate_sta_benefit,),
|
||||||
|
"Speech & Text Analytics [concurrent]": (calculate_sta_benefit,),
|
||||||
|
"Voice Bot": (calculate_va_deflection_benefit,),
|
||||||
|
"Agentic Virtual Agent": (calculate_va_deflection_benefit,),
|
||||||
|
"Predictive Routing": (calculate_predictive_routing_benefit,),
|
||||||
|
}
|
||||||
|
|
||||||
|
_COPILOT_FEATURES = {"Agent Copilot [named]", "Agent Copilot [concurrent]"}
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Voice Bot and Agentic VA deflection benefits use the layered
|
||||||
|
sequential model: the bot deflects from the full call pool; the VA
|
||||||
|
deflects from the residual. The two features are NOT additive on
|
||||||
|
the same base — see :func:`calculate_va_deflection_benefit`.
|
||||||
|
"""
|
||||||
|
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
|
||||||
|
frames: list[pd.DataFrame] = []
|
||||||
|
# Find whichever Copilot variant is in scope (named or concurrent).
|
||||||
|
copilot_scope = next(
|
||||||
|
(s for s in feature_scopes if s.feature in _COPILOT_FEATURES), None
|
||||||
|
)
|
||||||
|
|
||||||
|
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,313 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
token metering,
|
||||||
|
https://help.genesys.cloud/articles/genesys-cloud-tokens-model/
|
||||||
|
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":
|
||||||
|
# Layered model: VA operates on the residual volume after the voice bot
|
||||||
|
# has already deflected its share. Cost base = residual × va_rate.
|
||||||
|
# This is consistent with the benefit model and avoids double-counting
|
||||||
|
# the same call pool across both deflection mechanisms.
|
||||||
|
bot_deflection = scenario.voice_bot_deflection
|
||||||
|
va_deflection = (
|
||||||
|
scope.deflection_target
|
||||||
|
if scope.deflection_target is not None
|
||||||
|
else scenario.agentic_va_deflection
|
||||||
|
)
|
||||||
|
residual = site.voice_volume_monthly * (1.0 - bot_deflection)
|
||||||
|
return residual * va_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
|
||||||
|
if feature == "AI Translate":
|
||||||
|
# Each voice interaction generates one translation; eligibility_pct
|
||||||
|
# can be used to scope to a subset of interactions (e.g. non-English only).
|
||||||
|
eligibility = scope.eligibility_pct if scope.eligibility_pct is not None else 1.0
|
||||||
|
return site.voice_volume_monthly * eligibility # translations
|
||||||
|
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 per-user token rate already includes interaction summarization.
|
||||||
|
# https://help.genesys.cloud/articles/genesys-cloud-tokens-model/
|
||||||
|
_COPILOT_FEATURES = {"Agent Copilot [named]", "Agent Copilot [concurrent]"}
|
||||||
|
copilot_sites: set[str] = set()
|
||||||
|
for scope in feature_scopes:
|
||||||
|
if scope.feature in _COPILOT_FEATURES:
|
||||||
|
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,430 @@
|
|||||||
|
"""
|
||||||
|
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_METERS = (
|
||||||
|
"https://help.genesys.cloud/articles/genesys-cloud-tokens-model/"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 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 [
|
||||||
|
# ── Voice / Bot ───────────────────────────────────────────────
|
||||||
|
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_METERS,
|
||||||
|
),
|
||||||
|
TokenMeter(
|
||||||
|
feature="Digital Bot",
|
||||||
|
meter_type=MeterType.PER_INTERACTION,
|
||||||
|
units_per_token=51.0,
|
||||||
|
tokens_per_unit=1 / 51, # 0.0196
|
||||||
|
confidence=Confidence.CONFIRMED,
|
||||||
|
notes="Digital (non-voice) bot sessions; 51 sessions per token.",
|
||||||
|
source_url=_GENESYS_TOKEN_METERS,
|
||||||
|
),
|
||||||
|
# ── Virtual Agent ─────────────────────────────────────────────
|
||||||
|
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; 0.5 tokens per interaction.",
|
||||||
|
source_url=_GENESYS_TOKEN_METERS,
|
||||||
|
),
|
||||||
|
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_METERS,
|
||||||
|
),
|
||||||
|
# ── Agent Copilot (named vs concurrent) ───────────────────────
|
||||||
|
TokenMeter(
|
||||||
|
feature="Agent Copilot [named]",
|
||||||
|
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_METERS,
|
||||||
|
),
|
||||||
|
TokenMeter(
|
||||||
|
feature="Agent Copilot [concurrent]",
|
||||||
|
meter_type=MeterType.PER_USER_PER_MONTH,
|
||||||
|
units_per_token=0.0,
|
||||||
|
tokens_per_unit=60.0,
|
||||||
|
confidence=Confidence.CONFIRMED,
|
||||||
|
notes=(
|
||||||
|
"60 tokens per concurrent user per month. Includes interaction "
|
||||||
|
"summarization (covers AI Summary & Insights)."
|
||||||
|
),
|
||||||
|
source_url=_GENESYS_TOKEN_METERS,
|
||||||
|
),
|
||||||
|
# ── AI Quality / Analytics ────────────────────────────────────
|
||||||
|
TokenMeter(
|
||||||
|
feature="AI Scoring",
|
||||||
|
meter_type=MeterType.PER_INTERACTION,
|
||||||
|
units_per_token=20.0,
|
||||||
|
tokens_per_unit=0.05,
|
||||||
|
confidence=Confidence.CONFIRMED,
|
||||||
|
notes="AI-scored quality evaluations; 20 evaluations per token.",
|
||||||
|
source_url=_GENESYS_TOKEN_METERS,
|
||||||
|
),
|
||||||
|
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_METERS,
|
||||||
|
),
|
||||||
|
# ── Speech & Text Analytics (named vs concurrent) ─────────────
|
||||||
|
TokenMeter(
|
||||||
|
feature="Speech & Text Analytics [named]",
|
||||||
|
meter_type=MeterType.PER_USER_PER_MONTH,
|
||||||
|
units_per_token=0.0,
|
||||||
|
tokens_per_unit=30.0,
|
||||||
|
confidence=Confidence.CONFIRMED,
|
||||||
|
notes="STA named licence; 30 tokens per named user per month.",
|
||||||
|
source_url=_GENESYS_TOKEN_METERS,
|
||||||
|
),
|
||||||
|
TokenMeter(
|
||||||
|
feature="Speech & Text Analytics [concurrent]",
|
||||||
|
meter_type=MeterType.PER_USER_PER_MONTH,
|
||||||
|
units_per_token=0.0,
|
||||||
|
tokens_per_unit=45.0,
|
||||||
|
confidence=Confidence.CONFIRMED,
|
||||||
|
notes="STA concurrent licence; 45 tokens per concurrent user per month.",
|
||||||
|
source_url=_GENESYS_TOKEN_METERS,
|
||||||
|
),
|
||||||
|
# ── Routing / Engagement ──────────────────────────────────────
|
||||||
|
TokenMeter(
|
||||||
|
feature="Predictive Routing",
|
||||||
|
meter_type=MeterType.PER_INTERACTION,
|
||||||
|
units_per_token=17.0,
|
||||||
|
tokens_per_unit=1 / 17, # 0.0588
|
||||||
|
confidence=Confidence.CONFIRMED,
|
||||||
|
notes="Predictive routing; 17 routes per token.",
|
||||||
|
source_url=_GENESYS_TOKEN_METERS,
|
||||||
|
),
|
||||||
|
# ── Messaging ─────────────────────────────────────────────────
|
||||||
|
TokenMeter(
|
||||||
|
feature="Direct Messaging",
|
||||||
|
meter_type=MeterType.PER_MESSAGE,
|
||||||
|
units_per_token=400.0,
|
||||||
|
tokens_per_unit=0.0025,
|
||||||
|
confidence=Confidence.CONFIRMED,
|
||||||
|
notes=(
|
||||||
|
"Apple Messages for Business, Facebook Messenger, Instagram DM, "
|
||||||
|
"WhatsApp, and X (Twitter) DM; 400 inbound or outbound messages "
|
||||||
|
"per token. Additional carrier charges apply for WhatsApp and X."
|
||||||
|
),
|
||||||
|
source_url=_GENESYS_TOKEN_METERS,
|
||||||
|
),
|
||||||
|
TokenMeter(
|
||||||
|
feature="Social Listening",
|
||||||
|
meter_type=MeterType.PER_MESSAGE,
|
||||||
|
units_per_token=400.0,
|
||||||
|
tokens_per_unit=0.0025,
|
||||||
|
confidence=Confidence.CONFIRMED,
|
||||||
|
notes="Genesys Cloud Social; 400 social post ingestions per channel per token.",
|
||||||
|
source_url=_GENESYS_TOKEN_METERS,
|
||||||
|
),
|
||||||
|
TokenMeter(
|
||||||
|
feature="Social Responses",
|
||||||
|
meter_type=MeterType.PER_MESSAGE,
|
||||||
|
units_per_token=400.0,
|
||||||
|
tokens_per_unit=0.0025,
|
||||||
|
confidence=Confidence.CONFIRMED,
|
||||||
|
notes="Social Post Responses; 400 outbound messages per channel per token.",
|
||||||
|
source_url=_GENESYS_TOKEN_METERS,
|
||||||
|
),
|
||||||
|
# ── Language / Translation ────────────────────────────────────
|
||||||
|
TokenMeter(
|
||||||
|
feature="AI Translate",
|
||||||
|
meter_type=MeterType.PER_INTERACTION,
|
||||||
|
units_per_token=2.0,
|
||||||
|
tokens_per_unit=0.5,
|
||||||
|
confidence=Confidence.CONFIRMED,
|
||||||
|
notes="AI translation; 2 translations per token.",
|
||||||
|
source_url=_GENESYS_TOKEN_METERS,
|
||||||
|
),
|
||||||
|
# ── Genesys Cloud Copilot ─────────────────────────────────────
|
||||||
|
TokenMeter(
|
||||||
|
feature="Genesys Cloud Copilot",
|
||||||
|
meter_type=MeterType.PER_INTERACTION,
|
||||||
|
units_per_token=20.0,
|
||||||
|
tokens_per_unit=0.05,
|
||||||
|
confidence=Confidence.CONFIRMED,
|
||||||
|
notes=(
|
||||||
|
"20 AI actions per token; Genesys Cloud knowledge queries "
|
||||||
|
"are not charged."
|
||||||
|
),
|
||||||
|
source_url=_GENESYS_TOKEN_METERS,
|
||||||
|
),
|
||||||
|
# ── Email AI (rates not yet published) ────────────────────────
|
||||||
|
TokenMeter(
|
||||||
|
feature="Email AI (Auto-Suggest)",
|
||||||
|
meter_type=MeterType.PER_USER_PER_MONTH,
|
||||||
|
units_per_token=0.0,
|
||||||
|
tokens_per_unit=0.0,
|
||||||
|
confidence=Confidence.UNKNOWN,
|
||||||
|
notes="Requires Agent Copilot. Token rate not yet published.",
|
||||||
|
),
|
||||||
|
TokenMeter(
|
||||||
|
feature="Email AI (Auto-Respond)",
|
||||||
|
meter_type=MeterType.PER_MESSAGE,
|
||||||
|
units_per_token=0.0,
|
||||||
|
tokens_per_unit=0.0,
|
||||||
|
confidence=Confidence.UNKNOWN,
|
||||||
|
notes="Feature not yet available; rate TBD.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 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}),
|
||||||
|
# CTM has named licences — use the [named] variant for both STA and Copilot.
|
||||||
|
FeatureScope("Speech & Text Analytics [named]", ALL_SITE_NAMES, phase=1),
|
||||||
|
FeatureScope("Agent Copilot [named]", 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("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,155 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
licence_type: str = "named" # "named" | "concurrent"
|
||||||
|
languages: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.licence_type not in ("named", "concurrent"):
|
||||||
|
raise ValueError(
|
||||||
|
f"{self.site_name}: licence_type must be 'named' or 'concurrent', "
|
||||||
|
f"got {self.licence_type!r}"
|
||||||
|
)
|
||||||
|
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,149 @@
|
|||||||
|
"""
|
||||||
|
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 is INCREMENTAL — applied to the residual volume
|
||||||
|
# after the voice bot has already handled its share (layered model).
|
||||||
|
# Effective total deflection = bot_rate + (1 − bot_rate) × va_rate.
|
||||||
|
agentic_va_deflection: float # share of RESIDUAL 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
|
||||||
|
|
||||||
|
# ── Virtual Agent benefit realization factors ───────────────────
|
||||||
|
# Applied to both Voice Bot and Agentic VA deflection benefits.
|
||||||
|
# completion_rate — share of "deflected" calls that don't escalate to an agent
|
||||||
|
# mid-session (bot/VA fully handles the interaction).
|
||||||
|
# labour_realization — staffing flexibility: deflected volume doesn't reduce
|
||||||
|
# headcount 1:1 due to minimums, shrinkage, occupancy ceilings.
|
||||||
|
# callback_discount — fraction of deflected calls that re-enter as repeat contacts
|
||||||
|
# (poorly-handled deflections drive callbacks).
|
||||||
|
# Combined realistic factor: 0.70 × 0.80 × (1 − 0.05) ≈ 0.53
|
||||||
|
va_completion_rate: float = 0.70
|
||||||
|
va_labour_realization: float = 0.80
|
||||||
|
va_callback_discount: float = 0.05
|
||||||
|
|
||||||
|
# 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.40, "realistic": 0.30}, # × acceptance; Genesys claims 40%
|
||||||
|
# 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},
|
||||||
|
# Virtual Agent realization factors.
|
||||||
|
# ``claim`` = 100% realization (original model assumption — no haircuts).
|
||||||
|
# ``realistic`` = production-calibrated midpoints per the spec analysis.
|
||||||
|
"va_completion_rate": {"claim": 1.00, "realistic": 0.70}, # 60-75% voice bot; 50-70% agentic VA Y1
|
||||||
|
"va_labour_realization": {"claim": 1.00, "realistic": 0.80}, # 70-85% staffing flexibility
|
||||||
|
"va_callback_discount": {"claim": 0.00, "realistic": 0.05}, # 5-10% deflected re-enter as repeat contacts
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
# VA realization: conservative — low completion, limited staffing flex
|
||||||
|
# Combined: 0.60 × 0.70 × (1 − 0.05) ≈ 0.40
|
||||||
|
va_completion_rate=0.60,
|
||||||
|
va_labour_realization=0.70,
|
||||||
|
va_callback_discount=0.05,
|
||||||
|
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,
|
||||||
|
# VA realization: production midpoints per spec analysis
|
||||||
|
# Combined: 0.70 × 0.80 × (1 − 0.05) ≈ 0.53
|
||||||
|
va_completion_rate=0.70,
|
||||||
|
va_labour_realization=0.80,
|
||||||
|
va_callback_discount=0.05,
|
||||||
|
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,
|
||||||
|
# VA realization: optimistic — high completion, good staffing flexibility
|
||||||
|
# Combined: 0.75 × 0.85 × (1 − 0.03) ≈ 0.62
|
||||||
|
va_completion_rate=0.75,
|
||||||
|
va_labour_realization=0.85,
|
||||||
|
va_callback_discount=0.03,
|
||||||
|
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
|
||||||
56
studies/202512_GenesysCX/docs/Genesys-Token-Metering.md
Normal file
56
studies/202512_GenesysCX/docs/Genesys-Token-Metering.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Genesys Cloud AI Experience metering details
|
||||||
|
2026-06-07
|
||||||
|
https://help.genesys.cloud/articles/genesys-cloud-tokens-model/
|
||||||
|
This table describes the Genesys Cloud AI product’s raw meter and how many tokens are consumed.
|
||||||
|
Note: Direct Messaging (DM) channels include inbound and outbound messages. Social Responses messages include outbound messages only.
|
||||||
|
|
||||||
|
Genesys Cloud product Units per token
|
||||||
|
Bots (Voice) 17 minutes per token. For more information, see the AI section of the Genesys Cloud pricing hub.
|
||||||
|
Bots (Digital) 51 sessions per token. For more information, see the AI section of the Genesys Cloud pricing hub.
|
||||||
|
Virtual Agent 0.5 tokens per Virtual Agent interaction - An interaction is defined in accordance with existing Genesys Cloud token-based pricing.
|
||||||
|
Agentic Virtual Agent 1.2 tokens per interaction - An interaction is defined in accordance with existing Genesys Cloud token-based pricing.
|
||||||
|
Agent Copilot [concurrent] One user requires 60 tokens
|
||||||
|
Agent Copilot [named] One user requires 40 tokens
|
||||||
|
AI Scoring 20 evaluations scored with AI scoring per token
|
||||||
|
AI Translate Two translations per token
|
||||||
|
AI Summary and Insights
|
||||||
|
50 AI summaries/insights per token. However, if you enable Agent Copilot simultaneously, then Supervisor Copilot summaries and insights do not consume tokens and instead rely on Agent Copilot functionality.
|
||||||
|
Apple Messages for Business 400 inbound or outbound messages per token.
|
||||||
|
Facebook Messenger† 400 inbound or outbound messages per token.
|
||||||
|
Instagram Direct Messaging† 400 inbound or outbound messages per token.
|
||||||
|
WhatsApp Messaging† 400 inbound or outbound messages per token.
|
||||||
|
|
||||||
|
Other charges apply for WhatsApp. For more information, see the Messaging section of the Genesys Cloud pricing hub.
|
||||||
|
|
||||||
|
X (formerly Twitter) Direct Messaging 400 inbound or outbound messages per token.
|
||||||
|
|
||||||
|
Other charges apply for X integrations. For more information, see the Messaging section of the Genesys Cloud pricing hub.
|
||||||
|
|
||||||
|
Genesys Cloud Social 400 social post ingestions per channel per token. For more information, see the Messaging section of the Genesys Cloud pricing hub.
|
||||||
|
|
||||||
|
Social Post Responses 400 outbound messages per channel per token.
|
||||||
|
|
||||||
|
Predictive Engagement No charge for token usage. For more information, see Can I use digital user tracking at no additional cost? and Predictive Engagement and digital user tracking.
|
||||||
|
Predictive routing 17 routes per token. One token is consumed for every 17 interactions routed with predictive routing. For more information, see Predictive routing overview.
|
||||||
|
Speech and Text Analytics [named] One user requires 30 tokens
|
||||||
|
Speech and Text Analytics [concurrent] One user requires 45 tokens
|
||||||
|
Genesys Cloud Copilot 20 AI actions per token, no charge for Genesys Cloud knowledge queries. For more information, see Genesys Cloud Copilot AI actions overview.
|
||||||
|
|
||||||
|
† For Facebook, Instagram, and WhatsApp: If the organization has not moved to the Genesys Cloud AI Experience token pricing, then legacy, conversation-based pricing applies. Other charges apply for X integrations. For more information, see Messaging in the Genesys Cloud pricing hub.
|
||||||
|
|
||||||
|
|
||||||
|
## Virtual Agent interactions explained
|
||||||
|
A single interaction is contained by a single billingID. A billingID represents a single interaction on any channel of any length. A single interaction is delimited by end interaction events. A single billingID can contain multiple end interaction events.
|
||||||
|
|
||||||
|
The following actions trigger end interaction events:
|
||||||
|
|
||||||
|
Exit action in flow (return calling flow)
|
||||||
|
Disconnect action in the flow
|
||||||
|
Disconnect when the participant hangs up the phone
|
||||||
|
Disconnect via a Transfer to ACD action
|
||||||
|
Exit or disconnect handling for an unexpected error
|
||||||
|
Exit or disconnect handing for a recognition failure (continuous no matches or no inputs)
|
||||||
|
Exit or disconnect handling for Max No Input override (if set, overrides recognition failure settings)
|
||||||
|
Exit handling for agent escalation
|
||||||
|
Digital expiry after inactivity (72-hour async timeout)
|
||||||
|
When Genesys Cloud transfers interactions between inbound flows and Virtual Agent flows, the same billingID remains. When an action triggers an end interaction event and transfers no longer occur, then Genesys Cloud closes the billingID.
|
||||||
0
studies/202512_GenesysCX/exports/.gitkeep
Normal file
0
studies/202512_GenesysCX/exports/.gitkeep
Normal file
934
studies/202512_GenesysCX/notebooks/00_provision.ipynb
Normal file
934
studies/202512_GenesysCX/notebooks/00_provision.ipynb
Normal file
@@ -0,0 +1,934 @@
|
|||||||
|
{
|
||||||
|
"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 (2 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": [
|
||||||
|
"Found existing report template UCb2hSJprSBx (status: active)\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",
|
||||||
|
"`03_business_case.ipynb`."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 3,
|
||||||
|
"id": "55e69828",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"0 fields created, 12 already existed.\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": 5,
|
||||||
|
"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": 6,
|
||||||
|
"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": 7,
|
||||||
|
"id": "e04b1676",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"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": 8,
|
||||||
|
"id": "0655d1fc",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Found existing tool 3rzDgVdsjhVv (status: draft)\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": 9,
|
||||||
|
"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": 10,
|
||||||
|
"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": 11,
|
||||||
|
"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": 12,
|
||||||
|
"id": "d8102590",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"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_benefits.ipynb (walk through the four Forrester benefits).\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 03_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_benefits.ipynb (walk through the four Forrester benefits).\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "4fc81c99-f073-486a-9f65-f207e96e59cd",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "13acdc34-71f6-4220-8675-4e1527cb8e39",
|
||||||
|
"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
|
||||||
|
}
|
||||||
1408
studies/202512_GenesysCX/notebooks/01_benefits.ipynb
Normal file
1408
studies/202512_GenesysCX/notebooks/01_benefits.ipynb
Normal file
File diff suppressed because one or more lines are too long
1508
studies/202512_GenesysCX/notebooks/02_costs.ipynb
Normal file
1508
studies/202512_GenesysCX/notebooks/02_costs.ipynb
Normal file
File diff suppressed because one or more lines are too long
382
studies/202512_GenesysCX/notebooks/03_business_case.ipynb
Normal file
382
studies/202512_GenesysCX/notebooks/03_business_case.ipynb
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g3-md-intro",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# 03 \u2014 Business Case\n",
|
||||||
|
"\n",
|
||||||
|
"Combine the benefits and costs into the consolidated TEI summary,\n",
|
||||||
|
"render the cash-flow exhibit, run scenario analysis, **and price the\n",
|
||||||
|
"Genesys AI Experience tokens line that the published study omits**.\n",
|
||||||
|
"This notebook should reproduce the headline numbers from the PDF\n",
|
||||||
|
"Financial Summary:\n",
|
||||||
|
"\n",
|
||||||
|
"* **NPV \\$10.78M \u2022 ROI 266% \u2022 Payback \u2248 4 months**\n",
|
||||||
|
"\n",
|
||||||
|
"It then exposes a sensitivity sweep for the AI-tokens annual cost so\n",
|
||||||
|
"you can see exactly what an honest deal looks like before sending it\n",
|
||||||
|
"to a client."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "03-bootstrap",
|
||||||
|
"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",
|
||||||
|
"from core.bootstrap import init\n",
|
||||||
|
"\n",
|
||||||
|
"pal = init(study=\"202512_GenesysCX\")\n",
|
||||||
|
"client, seed, config = pal.client, pal.seed_data, pal.config\n",
|
||||||
|
"\n",
|
||||||
|
"STUDY = pal.root / 'studies' / '202512_GenesysCX'\n",
|
||||||
|
"ROOT = pal.root\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g3-code-imports",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"from core.export.report_data import _compute_summary\n",
|
||||||
|
"from core.notebook_helpers import charts, display, tables"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g3-md-summary",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Local summary (no Athena round-trip)\n",
|
||||||
|
"\n",
|
||||||
|
"Compute the moderate-case TEI summary directly from `seed_data` so the\n",
|
||||||
|
"notebook produces results even before the Athena tool is provisioned.\n",
|
||||||
|
"Headline numbers should match the published study."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g3-code-summary",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"summary = _compute_summary(\n",
|
||||||
|
" seed.BENEFITS,\n",
|
||||||
|
" seed.COSTS,\n",
|
||||||
|
" config.DISCOUNT_RATE,\n",
|
||||||
|
" config.ANALYSIS_YEARS,\n",
|
||||||
|
")\n",
|
||||||
|
"# `_compute_summary` returns roi_pct; expose it as `roi` for kpi_cards.\n",
|
||||||
|
"summary['roi'] = summary.get('roi_pct')\n",
|
||||||
|
"display.kpi_cards(summary, title='Forrester composite \u2014 moderate case')"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g3-code-cashflow-table",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"df_cash = tables.cashflow_table(summary)\n",
|
||||||
|
"df_cash.style.format({c: '${:,.0f}' for c in df_cash.columns if c != 'Year'})"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g3-md-cashflow",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Cash flow chart\n",
|
||||||
|
"\n",
|
||||||
|
"Mirrors the Forrester *Cash Flow Chart* exhibit: stacked benefits/costs\n",
|
||||||
|
"by year + cumulative-net line. Payback hits inside Year 1."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g3-code-cashflow-chart",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"charts.cashflow_chart(\n",
|
||||||
|
" summary['yearly_breakdown'],\n",
|
||||||
|
" initial_cost=summary.get('initial_costs', 0),\n",
|
||||||
|
").show()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g3-md-waterfall",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Waterfall: Benefits PV \u2192 Costs PV \u2192 NPV"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g3-code-waterfall",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"charts.waterfall([\n",
|
||||||
|
" ('Benefits PV', summary['total_benefits_pv']),\n",
|
||||||
|
" ('Costs PV', -summary['total_costs_pv']),\n",
|
||||||
|
" ('NPV', summary['npv']),\n",
|
||||||
|
"]).show()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g3-md-scenarios",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Scenario analysis\n",
|
||||||
|
"\n",
|
||||||
|
"Apply the default Palladium multipliers (see `core.calculations.SCENARIOS`):\n",
|
||||||
|
"\n",
|
||||||
|
"* **Conservative** \u2014 lower adoption, higher risk on benefits / lower on costs\n",
|
||||||
|
"* **Moderate** \u2014 base case (= the published Forrester study)\n",
|
||||||
|
"* **Aggressive** \u2014 full adoption, lower risk on benefits / higher on costs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g3-code-scenarios",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"from core.calculations import apply_scenario\n",
|
||||||
|
"import pandas as pd\n",
|
||||||
|
"\n",
|
||||||
|
"scenario_summaries = {}\n",
|
||||||
|
"for name in ('conservative', 'moderate', 'aggressive'):\n",
|
||||||
|
" sb = apply_scenario(seed.BENEFITS, name, table='benefits')\n",
|
||||||
|
" sc = apply_scenario(seed.COSTS, name, table='costs')\n",
|
||||||
|
" scenario_summaries[name] = _compute_summary(sb, sc, config.DISCOUNT_RATE, config.ANALYSIS_YEARS)\n",
|
||||||
|
"\n",
|
||||||
|
"scen_df = pd.DataFrame([\n",
|
||||||
|
" {\n",
|
||||||
|
" 'Scenario': k,\n",
|
||||||
|
" 'Benefits PV': v['total_benefits_pv'],\n",
|
||||||
|
" 'Costs PV': v['total_costs_pv'],\n",
|
||||||
|
" 'NPV': v['npv'],\n",
|
||||||
|
" 'ROI %': v['roi_pct'],\n",
|
||||||
|
" 'Payback (mo)': round(v['payback_months'], 1) if v['payback_months'] is not None else None,\n",
|
||||||
|
" }\n",
|
||||||
|
" for k, v in scenario_summaries.items()\n",
|
||||||
|
"])\n",
|
||||||
|
"scen_df.style.format({\n",
|
||||||
|
" 'Benefits PV': '${:,.0f}', 'Costs PV': '${:,.0f}', 'NPV': '${:,.0f}', 'ROI %': '{:,.0f}%'\n",
|
||||||
|
"})"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g3-code-scenario-chart",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"charts.scenario_comparison(scenario_summaries).show()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g3-md-tokens-intro",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Genesys AI Experience tokens \u2014 annual cost\n",
|
||||||
|
"\n",
|
||||||
|
"Token pricing is tiered, capability-dependent, and deal-specific \u2014\n",
|
||||||
|
"Athena stores a single annual cost value per line, and so does the\n",
|
||||||
|
"seed. Enter the negotiated annual cost from the Genesys quote here.\n",
|
||||||
|
"Quote details (volume, unit price, tier) go into the field notes for\n",
|
||||||
|
"the audit trail.\n",
|
||||||
|
"\n",
|
||||||
|
"For sizing context, the study's own drivers imply roughly **1,040,000**\n",
|
||||||
|
"self-service interactions/yr and **3,120,000** agent-assisted\n",
|
||||||
|
"interactions/yr would draw tokens \u2014 bring the actual figure from the\n",
|
||||||
|
"quote, not a derivation."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g3-code-token-input",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"# \u2500\u2500 Deal inputs \u2500\u2500\n",
|
||||||
|
"AI_TOKEN_ANNUAL_COST = 0.0 # $/yr from the Genesys quote \u2014 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": "g3-md-sensitivity",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### Sensitivity \u2014 what the AI line does to NPV and ROI\n",
|
||||||
|
"\n",
|
||||||
|
"An annual cost `\u0394` raises Costs PV by `\u0394 \u00d7 2.4869` (the 3-year, 10%\n",
|
||||||
|
"annuity factor) and lowers NPV by the same amount. The sweep below\n",
|
||||||
|
"shows where the deal stops being attractive \u2014 and quantifies how much\n",
|
||||||
|
"of the published 266% ROI was *contingent on Forrester modelling \\$0\n",
|
||||||
|
"of token spend*."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g3-code-sensitivity",
|
||||||
|
"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",
|
||||||
|
"sweep = [0, 100_000, 250_000, 500_000, 750_000, 1_000_000, 1_500_000, 2_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_v = base_benefits_pv - costs_pv\n",
|
||||||
|
" roi_pct = (npv_v / costs_pv * 100) if costs_pv else 0\n",
|
||||||
|
" rows.append({\n",
|
||||||
|
" 'AI cost/yr': f\"${ai_annual:,.0f}\" + (' \u2190 your input' if ai_annual == AI_TOKEN_ANNUAL_COST and ai_annual else ''),\n",
|
||||||
|
" 'Costs PV': f'${costs_pv:,.0f}',\n",
|
||||||
|
" 'NPV': f'${npv_v:,.0f}',\n",
|
||||||
|
" 'ROI': f'{roi_pct:,.0f}%',\n",
|
||||||
|
" })\n",
|
||||||
|
"\n",
|
||||||
|
"pd.DataFrame(rows)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g3-md-tokens-push",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### Push the AI-tokens cost to Athena\n",
|
||||||
|
"\n",
|
||||||
|
"When `AI_TOKEN_ANNUAL_COST` is set and `TOOL_PUBLIC_ID` exists, write\n",
|
||||||
|
"the annual cost into the `genesys_ai_tokens` field, with the quote\n",
|
||||||
|
"details preserved in the field notes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g3-code-tokens-push",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"PUSH = False # \u2190 set True once AI_TOKEN_ANNUAL_COST is final\n",
|
||||||
|
"\n",
|
||||||
|
"if PUSH and config.TOOL_PUBLIC_ID:\n",
|
||||||
|
" from core.tei_client import TEIClient\n",
|
||||||
|
"\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 = TEIClient()\n",
|
||||||
|
" client.update_values(config.TOOL_PUBLIC_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",
|
||||||
|
" client.calculate(config.TOOL_PUBLIC_ID)\n",
|
||||||
|
" client.print_summary(config.TOOL_PUBLIC_ID)\n",
|
||||||
|
" client.save_version(config.TOOL_PUBLIC_ID, note=f'AI token cost set: {note}')\n",
|
||||||
|
" display.alert('Pushed, recalculated, and versioned.', 'success')\n",
|
||||||
|
"else:\n",
|
||||||
|
" display.alert('Dry run \u2014 set <code>PUSH = True</code> and ensure '\n",
|
||||||
|
" '<code>TOOL_PUBLIC_ID</code> is configured to write to Athena.', 'info')"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g3-md-crosscheck",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Cross-check vs Athena (optional)\n",
|
||||||
|
"\n",
|
||||||
|
"When `TOOL_PUBLIC_ID` is set, ask Athena to recalculate the summary on\n",
|
||||||
|
"the server side and confirm it matches our local computation (modulo\n",
|
||||||
|
"the documented Year-0 discounting delta \u2014 see `02_costs.ipynb`)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g3-code-crosscheck",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"if config.TOOL_PUBLIC_ID:\n",
|
||||||
|
" from core.tei_client import TEIClient\n",
|
||||||
|
"\n",
|
||||||
|
" client = TEIClient()\n",
|
||||||
|
" client.calculate(config.TOOL_PUBLIC_ID)\n",
|
||||||
|
" server_summary = client.get_summary(config.TOOL_PUBLIC_ID)\n",
|
||||||
|
" display.kpi_cards(server_summary, title='Athena server-side summary')\n",
|
||||||
|
"else:\n",
|
||||||
|
" display.alert('Set TOOL_PUBLIC_ID to compare Athena vs local.', 'info')"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g3-md-next",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"Continue with [`04_export.ipynb`](04_export.ipynb) \u2192"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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
|
||||||
|
}
|
||||||
195
studies/202512_GenesysCX/notebooks/04_export.ipynb
Normal file
195
studies/202512_GenesysCX/notebooks/04_export.ipynb
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g4-md-intro",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# 04 \u2014 Export for the report pipeline\n",
|
||||||
|
"\n",
|
||||||
|
"Build the structured JSON envelope consumed by the html2docx report\n",
|
||||||
|
"generation pipeline (Peitho). Output goes to `exports/export.json`."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "04-bootstrap",
|
||||||
|
"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",
|
||||||
|
"from core.bootstrap import init\n",
|
||||||
|
"\n",
|
||||||
|
"pal = init(study=\"202512_GenesysCX\")\n",
|
||||||
|
"client, seed, config = pal.client, pal.seed_data, pal.config\n",
|
||||||
|
"\n",
|
||||||
|
"STUDY = pal.root / 'studies' / '202512_GenesysCX'\n",
|
||||||
|
"ROOT = pal.root\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g4-code-imports",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import json\n",
|
||||||
|
"from datetime import datetime, timezone\n",
|
||||||
|
"from core import __version__\n",
|
||||||
|
"from core.calculations import apply_scenario\n",
|
||||||
|
"from core.export.report_data import _compute_summary\n",
|
||||||
|
"from core.notebook_helpers import display"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g4-md-build",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Build the envelope\n",
|
||||||
|
"\n",
|
||||||
|
"Two paths:\n",
|
||||||
|
"\n",
|
||||||
|
"* **Live** \u2014 `core.export.build_report_data(client, public_id)` pulls\n",
|
||||||
|
" authoritative values + summary from Athena and stamps it.\n",
|
||||||
|
"* **Local** \u2014 when no `TOOL_PUBLIC_ID` is configured, build the envelope\n",
|
||||||
|
" directly from `seed_data` so this notebook is always runnable."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g4-code-build",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"if config.TOOL_PUBLIC_ID:\n",
|
||||||
|
" from core.export import build_report_data\n",
|
||||||
|
" from core.tei_client import TEIClient\n",
|
||||||
|
"\n",
|
||||||
|
" client = TEIClient()\n",
|
||||||
|
" envelope = build_report_data(\n",
|
||||||
|
" client,\n",
|
||||||
|
" config.TOOL_PUBLIC_ID,\n",
|
||||||
|
" include_scenarios=True,\n",
|
||||||
|
" study_slug=config.STUDY_SLUG,\n",
|
||||||
|
" )\n",
|
||||||
|
" source = 'live (Athena)'\n",
|
||||||
|
"else:\n",
|
||||||
|
" summary = _compute_summary(\n",
|
||||||
|
" seed.BENEFITS, seed.COSTS, config.DISCOUNT_RATE, config.ANALYSIS_YEARS\n",
|
||||||
|
" )\n",
|
||||||
|
" summary['roi'] = summary.get('roi_pct')\n",
|
||||||
|
" scenarios = {}\n",
|
||||||
|
" for name in ('conservative', 'moderate', 'aggressive'):\n",
|
||||||
|
" sb = apply_scenario(seed.BENEFITS, name, table='benefits')\n",
|
||||||
|
" sc = apply_scenario(seed.COSTS, name, table='costs')\n",
|
||||||
|
" scenarios[name] = _compute_summary(sb, sc, config.DISCOUNT_RATE, config.ANALYSIS_YEARS)\n",
|
||||||
|
" envelope = {\n",
|
||||||
|
" 'metadata': {\n",
|
||||||
|
" 'study_slug': config.STUDY_SLUG,\n",
|
||||||
|
" 'tool_public_id': '',\n",
|
||||||
|
" 'tool_name': 'CX Cloud (Genesys + Salesforce) TEI (local seed)',\n",
|
||||||
|
" 'report_name': 'Total Economic Impact\u2122 Of CX Cloud \u2014 Genesys + Salesforce',\n",
|
||||||
|
" 'report_vendor': 'Genesys',\n",
|
||||||
|
" 'report_version': '1.0',\n",
|
||||||
|
" 'generated_at': datetime.now(timezone.utc).isoformat(),\n",
|
||||||
|
" 'generator': f'palladium core {__version__} (offline)',\n",
|
||||||
|
" },\n",
|
||||||
|
" 'report': {\n",
|
||||||
|
" 'name': 'Total Economic Impact\u2122 Of CX Cloud \u2014 Genesys + Salesforce',\n",
|
||||||
|
" 'vendor': 'Genesys',\n",
|
||||||
|
" 'version': '1.0',\n",
|
||||||
|
" 'discount_rate': config.DISCOUNT_RATE,\n",
|
||||||
|
" 'analysis_period_years': config.ANALYSIS_YEARS,\n",
|
||||||
|
" },\n",
|
||||||
|
" 'values': {'benefits': seed.BENEFITS, 'costs': seed.COSTS},\n",
|
||||||
|
" 'summary': summary,\n",
|
||||||
|
" 'scenarios': scenarios,\n",
|
||||||
|
" 'assumptions': seed.ASSUMPTIONS,\n",
|
||||||
|
" }\n",
|
||||||
|
" source = 'offline seed data'\n",
|
||||||
|
"\n",
|
||||||
|
"display.alert(f'Envelope built from <b>{source}</b>.', 'info')"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g4-code-write",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"out_path = STUDY / 'exports' / 'export.json'\n",
|
||||||
|
"out_path.parent.mkdir(parents=True, exist_ok=True)\n",
|
||||||
|
"out_path.write_text(json.dumps(envelope, indent=2, default=str))\n",
|
||||||
|
"size_kb = out_path.stat().st_size / 1024\n",
|
||||||
|
"display.alert(f'Wrote <code>{out_path.relative_to(ROOT)}</code> ({size_kb:.1f} KB).', 'success')"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g4-md-shape",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Envelope shape\n",
|
||||||
|
"\n",
|
||||||
|
"Top-level keys consumed by the report pipeline:"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "g4-code-shape",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"for key in envelope:\n",
|
||||||
|
" sub = envelope[key]\n",
|
||||||
|
" if isinstance(sub, dict):\n",
|
||||||
|
" print(f' {key}: dict with keys {list(sub.keys())}')\n",
|
||||||
|
" elif isinstance(sub, list):\n",
|
||||||
|
" print(f' {key}: list[{len(sub)}]')\n",
|
||||||
|
" else:\n",
|
||||||
|
" print(f' {key}: {type(sub).__name__}')"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "g4-md-done",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"Done. Hand off `exports/export.json` to **Peitho** / **html2docx** to produce the final Word report.\n",
|
||||||
|
"\n",
|
||||||
|
"**CLI alternative:** `python -m palladium export $PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID -o studies/202512_GenesysCX/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
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 5,
|
"execution_count": 2,
|
||||||
"id": "5bcc7740",
|
"id": "5bcc7740",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 6,
|
"execution_count": 3,
|
||||||
"id": "386ae38b",
|
"id": "386ae38b",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 7,
|
"execution_count": 4,
|
||||||
"id": "dc46ab46",
|
"id": "dc46ab46",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -198,20 +198,89 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 12,
|
"execution_count": 5,
|
||||||
"id": "4070b9c2",
|
"id": "4070b9c2",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
"ename": "AttributeError",
|
"data": {
|
||||||
"evalue": "'TEIClient' object has no attribute 'list_clients'",
|
"text/html": [
|
||||||
"output_type": "error",
|
"<div>\n",
|
||||||
"traceback": [
|
"<style scoped>\n",
|
||||||
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
|
" .dataframe tbody tr th:only-of-type {\n",
|
||||||
"\u001b[31mAttributeError\u001b[39m Traceback (most recent call last)",
|
" vertical-align: middle;\n",
|
||||||
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[12]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m CLIENT_SEARCH = \u001b[33m\"Global\"\u001b[39m \u001b[38;5;66;03m# e.g. \"Acme\" — empty lists everyone\u001b[39;00m\n\u001b[32m 2\u001b[39m \n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m clients = client.list_clients(search=CLIENT_SEARCH \u001b[38;5;28;01mor\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m)\n\u001b[32m 4\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m clients:\n\u001b[32m 5\u001b[39m display(pd.DataFrame(clients)[\n\u001b[32m 6\u001b[39m [c for c in (\"id\", \"name\", \"vertical\", \"client_type\", \"employee_count\",\n",
|
" }\n",
|
||||||
"\u001b[31mAttributeError\u001b[39m: 'TEIClient' object has no attribute 'list_clients'"
|
"\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": [
|
"source": [
|
||||||
@@ -230,24 +299,112 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 10,
|
"execution_count": 6,
|
||||||
"id": "4e97978c",
|
"id": "4e97978c",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
"ename": "NameError",
|
"name": "stdout",
|
||||||
"evalue": "name 'clients' is not defined",
|
"output_type": "stream",
|
||||||
"output_type": "error",
|
"text": [
|
||||||
"traceback": [
|
"\n",
|
||||||
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
|
"Client profile — no re-entry needed downstream:\n"
|
||||||
"\u001b[31mNameError\u001b[39m Traceback (most recent call last)",
|
|
||||||
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[10]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m CLIENT_ID = \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;66;03m# ← set from the `id` column above, or leave for auto-pick\u001b[39;00m\n\u001b[32m 2\u001b[39m \n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m CLIENT_ID \u001b[38;5;28;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;28;01mand\u001b[39;00m len(clients) == \u001b[32m1\u001b[39m:\n\u001b[32m 4\u001b[39m CLIENT_ID = clients[\u001b[32m0\u001b[39m][\u001b[33m\"id\"\u001b[39m]\n\u001b[32m 5\u001b[39m print(f\"Auto-selected the only client: {clients[\u001b[32m0\u001b[39m][\u001b[33m'name'\u001b[39m]} (id={CLIENT_ID})\")\n\u001b[32m 6\u001b[39m \u001b[38;5;28;01massert\u001b[39;00m CLIENT_ID \u001b[38;5;28;01mis\u001b[39;00m \u001b[38;5;28;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m, \u001b[33m\"Set CLIENT_ID from the table above and re-run this cell.\"\u001b[39m\n",
|
|
||||||
"\u001b[31mNameError\u001b[39m: name 'clients' is not defined"
|
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"CLIENT_ID = None # ← set from the `id` column above, or leave for auto-pick\n",
|
"CLIENT_ID = 2 # ← set from the `id` column above, or leave for auto-pick\n",
|
||||||
"\n",
|
"\n",
|
||||||
"if CLIENT_ID is None and len(clients) == 1:\n",
|
"if CLIENT_ID is None and len(clients) == 1:\n",
|
||||||
" CLIENT_ID = clients[0][\"id\"]\n",
|
" CLIENT_ID = clients[0][\"id\"]\n",
|
||||||
@@ -274,19 +431,60 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": 11,
|
"execution_count": 7,
|
||||||
"id": "fcccc591",
|
"id": "fcccc591",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
"ename": "NameError",
|
"data": {
|
||||||
"evalue": "name 'profile' is not defined",
|
"text/html": [
|
||||||
"output_type": "error",
|
"<div>\n",
|
||||||
"traceback": [
|
"<style scoped>\n",
|
||||||
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
|
" .dataframe tbody tr th:only-of-type {\n",
|
||||||
"\u001b[31mNameError\u001b[39m Traceback (most recent call last)",
|
" vertical-align: middle;\n",
|
||||||
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[11]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m CLIENT_ASSUMPTIONS = dict(seed.ASSUMPTIONS)\n\u001b[32m 2\u001b[39m overrides = {\n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[33m\"agents_fte\"\u001b[39m: profile.get(\u001b[33m\"contact_center_agent_count\"\u001b[39m),\n\u001b[32m 4\u001b[39m \u001b[33m\"supervisors_fte\"\u001b[39m: profile.get(\u001b[33m\"supervisor_count\"\u001b[39m),\n\u001b[32m 5\u001b[39m }\n\u001b[32m 6\u001b[39m rows = []\n",
|
" }\n",
|
||||||
"\u001b[31mNameError\u001b[39m: name 'profile' is not defined"
|
"\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>assumption</th>\n",
|
||||||
|
" <th>Forrester composite</th>\n",
|
||||||
|
" <th>Global Guardian Insurance (CRM)</th>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" </thead>\n",
|
||||||
|
" <tbody>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>0</th>\n",
|
||||||
|
" <td>agents_fte</td>\n",
|
||||||
|
" <td>2000</td>\n",
|
||||||
|
" <td>2500</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" </tbody>\n",
|
||||||
|
"</table>\n",
|
||||||
|
"</div>"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
" assumption Forrester composite Global Guardian Insurance (CRM)\n",
|
||||||
|
"0 agents_fte 2000 2500"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Indicative scale factor vs composite: 1.25× (apply judgement — benefits don't all scale linearly)\n"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -326,10 +524,70 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": 8,
|
||||||
"id": "57dec6cf",
|
"id": "57dec6cf",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"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",
|
||||||
|
" <th>due_date</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",
|
||||||
|
" <td>2026-08-28</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 due_date \n",
|
||||||
|
"0 Secure Cloud Infrastructure Modernization 2026-08-28 "
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
}
|
||||||
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"proposals = client.proposals_for_client(CLIENT_ID)\n",
|
"proposals = client.proposals_for_client(CLIENT_ID)\n",
|
||||||
"engagements = client.engagements_for_client(CLIENT_NAME)\n",
|
"engagements = client.engagements_for_client(CLIENT_NAME)\n",
|
||||||
@@ -356,10 +614,19 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": 9,
|
||||||
"id": "19336bcc",
|
"id": "19336bcc",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Auto-selected proposal 1: Secure Cloud Infrastructure Modernization\n",
|
||||||
|
"Attaching via: {'proposal': 1}\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"# Set exactly ONE of these (ids from the tables above). Leave both None to\n",
|
"# Set exactly ONE of these (ids from the tables above). Leave both None to\n",
|
||||||
"# auto-pick — single existing proposal/engagement wins; otherwise a sandbox\n",
|
"# auto-pick — single existing proposal/engagement wins; otherwise a sandbox\n",
|
||||||
@@ -406,10 +673,18 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": 10,
|
||||||
"id": "017ae9db",
|
"id": "017ae9db",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Created tool pkrsQ9SRf654 attached to {'proposal': 1}\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"from core.tei_client import AthenaAPIError\n",
|
"from core.tei_client import AthenaAPIError\n",
|
||||||
"\n",
|
"\n",
|
||||||
@@ -452,10 +727,18 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": 11,
|
||||||
"id": "20e2a736",
|
"id": "20e2a736",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Pushed values for 8 fields.\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"payload = []\n",
|
"payload = []\n",
|
||||||
"for b in seed.BENEFITS: # nominal; Athena risk-adjusts via the field definition\n",
|
"for b in seed.BENEFITS: # nominal; Athena risk-adjusts via the field definition\n",
|
||||||
@@ -487,10 +770,27 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": 12,
|
||||||
"id": "b7ac5d24",
|
"id": "b7ac5d24",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"════════════════════════════════════════════════════════\n",
|
||||||
|
" TEI Financial Summary\n",
|
||||||
|
"════════════════════════════════════════════════════════\n",
|
||||||
|
" Total Benefits (PV): $ 101,696,568\n",
|
||||||
|
" Total Costs (PV): $ 22,874,326\n",
|
||||||
|
"────────────────────────────────────────────────────────\n",
|
||||||
|
" Net Present Value: $ 78,822,242\n",
|
||||||
|
" ROI: 345%\n",
|
||||||
|
" Payback: 1.0 months\n",
|
||||||
|
"════════════════════════════════════════════════════════\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"summary = client.calculate(TOOL_ID)\n",
|
"summary = client.calculate(TOOL_ID)\n",
|
||||||
"client.print_summary(TOOL_ID)"
|
"client.print_summary(TOOL_ID)"
|
||||||
@@ -498,10 +798,90 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": 13,
|
||||||
"id": "13d84001",
|
"id": "13d84001",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"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</th>\n",
|
||||||
|
" <th>athena</th>\n",
|
||||||
|
" <th>diff</th>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" </thead>\n",
|
||||||
|
" <tbody>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>0</th>\n",
|
||||||
|
" <td>total_benefits_pv</td>\n",
|
||||||
|
" <td>101,696,791</td>\n",
|
||||||
|
" <td>101,696,568</td>\n",
|
||||||
|
" <td>-0.00%</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>1</th>\n",
|
||||||
|
" <td>total_costs_pv</td>\n",
|
||||||
|
" <td>22,983,076</td>\n",
|
||||||
|
" <td>22,874,326</td>\n",
|
||||||
|
" <td>-0.47%</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>2</th>\n",
|
||||||
|
" <td>net_present_value</td>\n",
|
||||||
|
" <td>78,713,715</td>\n",
|
||||||
|
" <td>78,822,242</td>\n",
|
||||||
|
" <td>+0.14%</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>3</th>\n",
|
||||||
|
" <td>roi_percentage</td>\n",
|
||||||
|
" <td>342</td>\n",
|
||||||
|
" <td>345</td>\n",
|
||||||
|
" <td>+0.76%</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" </tbody>\n",
|
||||||
|
"</table>\n",
|
||||||
|
"</div>"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
" metric published athena diff\n",
|
||||||
|
"0 total_benefits_pv 101,696,791 101,696,568 -0.00%\n",
|
||||||
|
"1 total_costs_pv 22,983,076 22,874,326 -0.47%\n",
|
||||||
|
"2 net_present_value 78,713,715 78,822,242 +0.14%\n",
|
||||||
|
"3 roi_percentage 342 345 +0.76%"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Payback: 1 months (published: <6 months)\n",
|
||||||
|
"✅ Verified — Athena reproduces the published Forrester totals.\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"# Published Forrester totals (3-yr risk-adjusted PV @ 10%)\n",
|
"# Published Forrester totals (3-yr risk-adjusted PV @ 10%)\n",
|
||||||
"PUBLISHED = {\n",
|
"PUBLISHED = {\n",
|
||||||
@@ -539,10 +919,22 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": 14,
|
||||||
"id": "148bdb2a",
|
"id": "148bdb2a",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Saved version 1 (baseline).\n",
|
||||||
|
"Saved to /Users/robert/git/palladium/.env:\n",
|
||||||
|
" PALLADIUM_REPORT_PUBLIC_ID=xsUTbjh4iDnJ\n",
|
||||||
|
" PALLADIUM_TOOL_PUBLIC_ID=pkrsQ9SRf654\n",
|
||||||
|
" PALLADIUM_PROPOSAL_ID=1\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"if not client.list_versions(TOOL_ID):\n",
|
"if not client.list_versions(TOOL_ID):\n",
|
||||||
" client.save_version(TOOL_ID, note=\"Baseline — published Forrester TEI figures (Feb 2026), moderate scenario.\")\n",
|
" client.save_version(TOOL_ID, note=\"Baseline — published Forrester TEI figures (Feb 2026), moderate scenario.\")\n",
|
||||||
@@ -578,6 +970,14 @@
|
|||||||
"- **Interactive editing** → `make app` / `streamlit run app/main.py` — the tool appears in the sidebar\n",
|
"- **Interactive editing** → `make app` / `streamlit run app/main.py` — the tool appears in the sidebar\n",
|
||||||
"- **CLI sanity check** → `python -m palladium summary $PALLADIUM_TOOL_PUBLIC_ID`"
|
"- **CLI sanity check** → `python -m palladium summary $PALLADIUM_TOOL_PUBLIC_ID`"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "e9285087-5a2d-4a8d-856c-802474432892",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user