Migrate Genesys CX Cloud TEI study to the pattern; retire Streamlit app
studies/202512_GenesysCX -> studies/202512_TEI_Genesys_CX_Cloud, rebuilt as pattern Variant 4 (TEI composite reproduction): - teicalc/ self-contained engine: Forrester's tables as the never-edited verbatim anchor (incl. the p.14 typo note and the $0 AI-token line), generic model/scenarios/staging carried over from the Amazon Connect study, ClientDrivers overlay (agents / weekly interactions / revenue, flat composite so no growth re-base) with ai_tokens_annual as a direct input for the token line the published study models at $0 - one deliverable notebook (business_case.ipynb): widget-pair sidebar drivers incl. the AI-token price, published-vs-overlay KPI columns, cash-flow/waterfall/scenario charts, verification gate, backstage JSON data appendix - gate + tests reproduce the published totals within $2: NPV $10.8M / ROI 266% (engine $10,783,466 / 265.79%; payback 3.3 months, not headlined in the PDF); 29 study tests, headless nbconvert green, stage simulation leak-free, exports carry the appendix - old Athena workflow (00_provision..04_export, config.py, seed_data.py, PALLADIUM_GENESYSCX_* keys, ATHENA_EXPECTED reconciliation) deleted; git history preserves it With the last legacy study migrated, the retirement lands too: - app/ (Streamlit UI) and core/notebook_helpers deleted; nothing else imported them - streamlit stripped from pyproject extras, requirements.txt, Makefile; .env.example reduced to the Athena keys; 00_setup.ipynb and core/bootstrap.py repointed at the pattern studies - root README reworked: self-contained studies + slim core/ Athena toolkit (tei_client, calculations, export, cli) All suites green: Genesys 29, Amazon Connect 27, CTM 55, template 7, root 58. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
21
.env.example
21
.env.example
@@ -2,24 +2,3 @@
|
||||
# which prompts for these and writes .env for you.
|
||||
ATHENA_BASE_URL=https://athena.ouranos.helu.ca
|
||||
ATHENA_API_KEY=your-api-key-here
|
||||
|
||||
# Optional — pre-set the active study + tool so notebooks/CLI pick them up
|
||||
# without editing config.py. 00_provision.ipynb writes these for you.
|
||||
# A TEI tool attaches to exactly ONE of proposal / engagement.
|
||||
# PALLADIUM_REPORT_PUBLIC_ID=
|
||||
# PALLADIUM_TOOL_PUBLIC_ID=
|
||||
# PALLADIUM_PROPOSAL_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=.
|
||||
|
||||
|
||||
108
00_setup.ipynb
108
00_setup.ipynb
@@ -4,21 +4,7 @@
|
||||
"cell_type": "markdown",
|
||||
"id": "021ac129",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 🛡️ Palladium — Setup & Connection\n",
|
||||
"\n",
|
||||
"**Start here.** This notebook gets you from a fresh clone to a working Athena connection.\n",
|
||||
"\n",
|
||||
"| Where things live | |\n",
|
||||
"|---|---|\n",
|
||||
"| `00_setup.ipynb` | ← you are here: credentials + connection check |\n",
|
||||
"| `studies/<slug>/notebooks/` | the actual TEI work, numbered `00_provision` → `04_export` |\n",
|
||||
"| `core/` | shared logic (API client, financial math) — you rarely edit this |\n",
|
||||
"| `app/` | Streamlit data-entry UI: `make app` or `streamlit run app/main.py` |\n",
|
||||
"| `.env` | your Athena URL + API key (gitignored; created below) |\n",
|
||||
"\n",
|
||||
"Run cells top to bottom. Re-run any time — every step is idempotent."
|
||||
]
|
||||
"source": "# 🛡️ Palladium — Setup & Connection\n\n**Start here.** This notebook gets you from a fresh clone to a working Athena connection.\n\n| Where things live | |\n|---|---|\n| `00_setup.ipynb` | ← you are here: credentials + connection check |\n| `studies/<slug>/` | self-contained pattern studies (own venv, engine, Mercury notebook) |\n| `core/` | shared logic (API client, financial math) — you rarely edit this |\n| `.env` | your Athena URL + API key (gitignored; created below) |\n\nRun cells top to bottom. Re-run any time — every step is idempotent."
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
@@ -37,32 +23,13 @@
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# Bootstrap — finds the repo root, loads .env, builds the API client.\n",
|
||||
"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, save_credentials\n",
|
||||
"\n",
|
||||
"pal = init(connect=False)\n",
|
||||
"pal"
|
||||
]
|
||||
"source": "# Bootstrap — finds the repo root, loads .env, builds the API client.\nimport sys, pathlib # path shim: works on a fresh kernel\nfor _p in [pathlib.Path.cwd(), *pathlib.Path.cwd().parents]:\n if (_p / \"pyproject.toml\").exists():\n sys.path.insert(0, str(_p)); break\n\nfrom core.bootstrap import init, save_credentials\n\npal = init(connect=False)\npal"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "7ca43976",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1 · Credentials\n",
|
||||
"\n",
|
||||
"Stored in `<repo>/.env` (gitignored). The cell below only prompts if no key is\n",
|
||||
"configured yet — paste the key at the prompt and it's saved for every future\n",
|
||||
"session, notebook, the CLI, and the Streamlit app.\n",
|
||||
"\n",
|
||||
"Current target: **https://athena.ouranos.helu.ca** (Ouranos sandbox — safe to experiment, no production data)."
|
||||
]
|
||||
"source": "## 1 · Credentials\n\nStored in `<repo>/.env` (gitignored). The cell below only prompts if no key is\nconfigured yet — paste the key at the prompt and it's saved for every future\nsession, notebook, the CLI, and the Streamlit app.\n\nCurrent target: **https://athena.ouranos.helu.ca** (Ouranos sandbox — safe to experiment, no production data)."
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
@@ -79,26 +46,13 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import os\n",
|
||||
"from getpass import getpass\n",
|
||||
"\n",
|
||||
"if not os.getenv(\"ATHENA_API_KEY\"):\n",
|
||||
" key = getpass(\"Athena API key (input hidden): \")\n",
|
||||
" path = save_credentials(api_key=key)\n",
|
||||
" print(f\"Saved → {path}\")\n",
|
||||
"else:\n",
|
||||
" print(f\"✅ Credentials already configured for {os.getenv('ATHENA_BASE_URL')}\")\n",
|
||||
" print(\" (To rotate the key: save_credentials(api_key='new-key'))\")"
|
||||
]
|
||||
"source": "import os\nfrom getpass import getpass\n\nif not os.getenv(\"ATHENA_API_KEY\"):\n key = getpass(\"Athena API key (input hidden): \")\n path = save_credentials(api_key=key)\n print(f\"Saved → {path}\")\nelse:\n print(f\"✅ Credentials already configured for {os.getenv('ATHENA_BASE_URL')}\")\n print(\" (To rotate the key: save_credentials(api_key='new-key'))\")"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "aa7464fd",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2 · Test the connection"
|
||||
]
|
||||
"source": "## 2 · Test the connection"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
@@ -128,19 +82,13 @@
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"pal = init() # builds the client and pings /api/v1/tei/reports/\n",
|
||||
"client = pal.client\n",
|
||||
"pal.connection"
|
||||
]
|
||||
"source": "pal = init() # builds the client and pings /api/v1/tei/reports/\nclient = pal.client\npal.connection"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "6877d6ae",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3 · What's in this Athena instance?"
|
||||
]
|
||||
"source": "## 3 · What's in this Athena instance?"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
@@ -209,19 +157,7 @@
|
||||
"output_type": "display_data"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"reports = client.list_reports()\n",
|
||||
"if reports:\n",
|
||||
" display(pd.DataFrame(reports)[\n",
|
||||
" [c for c in (\"id\", \"name\", \"vendor\", \"version\", \"status\",\n",
|
||||
" \"analysis_period_years\", \"discount_rate\",\n",
|
||||
" \"field_count\", \"instance_count\") if c in reports[0]]\n",
|
||||
" ])\n",
|
||||
"else:\n",
|
||||
" print(\"No TEI report templates yet — studies/202602_AmazonConnect/notebooks/00_provision.ipynb creates one.\")"
|
||||
]
|
||||
"source": "import pandas as pd\n\nreports = client.list_reports()\nif reports:\n display(pd.DataFrame(reports)[\n [c for c in (\"id\", \"name\", \"vendor\", \"version\", \"status\",\n \"analysis_period_years\", \"discount_rate\",\n \"field_count\", \"instance_count\") if c in reports[0]]\n ])\nelse:\n print(\"No TEI report templates yet.\")"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
@@ -237,31 +173,13 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"tools = client.list_tools()\n",
|
||||
"if tools:\n",
|
||||
" display(pd.DataFrame(tools)[\n",
|
||||
" [c for c in (\"id\", \"name\", \"status\", \"current_version\") if c in tools[0]]\n",
|
||||
" ])\n",
|
||||
"else:\n",
|
||||
" print(\"No TEI tool instances yet.\")"
|
||||
]
|
||||
"source": "tools = client.list_tools()\nif tools:\n display(pd.DataFrame(tools)[\n [c for c in (\"id\", \"name\", \"status\", \"current_version\") if c in tools[0]]\n ])\nelse:\n print(\"No TEI tool instances yet.\")"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "33114d67",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Next steps\n",
|
||||
"\n",
|
||||
"1. **Provision the Amazon Connect study** → open\n",
|
||||
" [`studies/202602_AmazonConnect/notebooks/00_provision.ipynb`](studies/202602_AmazonConnect/notebooks/00_provision.ipynb).\n",
|
||||
" It creates the report template + fields in the sandbox, creates a tool,\n",
|
||||
" seeds the Forrester values, calculates, and verifies the published totals\n",
|
||||
" (NPV \\$78.7M · ROI 342% · payback <6 months).\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`)."
|
||||
]
|
||||
"source": "## Next steps\n\n1. **Open a study** — each `studies/<slug>/` is self-contained (own venv +\n engine + verification gate): see its README, e.g.\n [`studies/202602_TEI_Amazon_Connect/`](studies/202602_TEI_Amazon_Connect/README.md)\n (NPV \\$78.7M · ROI 342%) or\n [`studies/202512_TEI_Genesys_CX_Cloud/`](studies/202512_TEI_Genesys_CX_Cloud/README.md)\n (NPV \\$10.8M · ROI 266%).\n2. **Serve a deliverable** → `mercury --working-dir notebooks/` from the study root.\n3. **Start a new study** → copy `template/MercuryNotebook/` per the\n [pattern](docs/Mercury_Notebook_Pattern_V1-00.md)."
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
@@ -269,7 +187,7 @@
|
||||
"id": "d20d824f-e464-4ff7-8191-10c2495842a0",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
"source": ""
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
@@ -277,7 +195,7 @@
|
||||
"id": "630ee935-7c7b-47e5-9c13-6285316823e2",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
"source": ""
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
@@ -285,7 +203,7 @@
|
||||
"id": "7eba3877-8e51-443f-9953-9d0a48425f9f",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
"source": ""
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
|
||||
6
Makefile
6
Makefile
@@ -4,7 +4,7 @@ VENV := .venv
|
||||
PY := $(VENV)/bin/python
|
||||
PIP := $(VENV)/bin/pip
|
||||
|
||||
.PHONY: setup lab app test lint format clean
|
||||
.PHONY: setup lab test lint format clean
|
||||
|
||||
## One-time: create venv, install deps + palladium (editable)
|
||||
setup:
|
||||
@@ -19,10 +19,6 @@ setup:
|
||||
lab:
|
||||
$(VENV)/bin/jupyter lab
|
||||
|
||||
## Launch the Streamlit data-entry app
|
||||
app:
|
||||
$(VENV)/bin/streamlit run app/main.py
|
||||
|
||||
## Run the test suite (no Athena connection needed — HTTP is mocked)
|
||||
test:
|
||||
$(PY) -m pytest tests/ -v
|
||||
|
||||
107
README.md
107
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
**TEI (Total Economic Impact) Calculator** — The strategic artifact that protects the business case.
|
||||
|
||||
Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic Impact analyses. It connects to [Athena](https://athena.nttdata.com) for data persistence, performs financial calculations (NPV, ROI, payback period), and exports structured data for the report generation pipeline.
|
||||
Palladium is a Jupyter-notebook toolkit for building Total Economic Impact analyses. Each study is a self-contained Mercury-served notebook deliverable (math in a study package, verification gate, LLM-readable exports); a small shared `core/` talks to [Athena](https://athena.nttdata.com) for client/opportunity context and server-side TEI tooling.
|
||||
|
||||
> *In Greek mythology, the Palladium was a sacred artifact of Athena that protected Troy. Whoever possessed it held strategic advantage. In our ecosystem, Palladium protects the deal — transforming discovery inputs into a financial case no CFO can ignore.*
|
||||
|
||||
@@ -12,23 +12,17 @@ Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Palladium │
|
||||
│ │
|
||||
│ studies/202512_GenesysCX/ ← legacy study (this path) │
|
||||
│ studies/YYYYMM_<Vendor>/ │
|
||||
│ ├─ notebooks/ ─┐ │
|
||||
│ ├─ seed_data.py │ │
|
||||
│ └─ config.py │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ core/ │ ←─ │ app/ │ │
|
||||
│ │ shared logic │ │ Streamlit │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ tei_client → ───────────────────► Athena API │
|
||||
│ calculations │
|
||||
│ export ──────────────────────────► export.json │
|
||||
│ notebook_helpers │
|
||||
│ cli │
|
||||
│ studies/YYYYMM_TEI_Vendor_Product/ ← self-contained study │
|
||||
│ studies/YYYYMM_Client_EngagementName/ (own venv + engine) │
|
||||
│ ├─ <studylib>/ ← ALL math, verbatim anchors │
|
||||
│ ├─ notebooks/ ← THE deliverable (Mercury-served) │
|
||||
│ ├─ tests/ ← pinned acceptance numbers │
|
||||
│ └─ exports/ ← .html/.md + JSON appendix (for LLMs, │
|
||||
│ and the Athena repository roadmap) │
|
||||
│ │
|
||||
│ core/ ← shared Athena toolkit (studies do NOT import it) │
|
||||
│ tei_client → ──────────────────────────► Athena API │
|
||||
│ calculations · export · cli · bootstrap │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -36,26 +30,25 @@ Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| **`studies/`** | One self-contained folder per engagement — own venv, engine package, Mercury notebook, tests, exports |
|
||||
| **`template/`** | Copy-me study scaffold — start here for new studies |
|
||||
| **`core/tei_client`** | Python API client for Athena's TEI endpoints |
|
||||
| **`core/calculations`** | Financial logic — NPV, ROI, payback, risk adjustment, scenarios |
|
||||
| **`core/export`** | Builds the structured JSON envelope consumed by the report pipeline |
|
||||
| **`core/notebook_helpers`** | Pandas tables, Plotly charts, IPython display widgets |
|
||||
| **`core/cli`** | `python -m palladium` command-line interface |
|
||||
| **`app/`** | Streamlit data-entry UI with version management — *study-agnostic* |
|
||||
| **`studies/`** | One folder per TEI engagement (notebooks, seed data, config, source PDF) |
|
||||
| **`template/`** | Copy-me study templates — start here for new studies |
|
||||
|
||||
> **New studies follow the [Mercury Notebook Deliverable Pattern](docs/Mercury_Notebook_Pattern_V1-00.md)**:
|
||||
> **All studies follow the [Mercury Notebook Deliverable Pattern](docs/Mercury_Notebook_Pattern_V1-00.md)**:
|
||||
> the notebook *is* the artifact — self-contained study package, Mercury-served,
|
||||
> gate-verified, LLM-exportable. Start from [`template/MercuryNotebook/`](template/MercuryNotebook/).
|
||||
> The Streamlit `app/` path is retired by that pattern; existing TEI studies migrate to it.
|
||||
> The Streamlit `app/` and `core/notebook_helpers` were retired when the last
|
||||
> legacy study migrated (git history keeps them).
|
||||
|
||||
---
|
||||
|
||||
## Quick Start — Jupyter Lab first
|
||||
|
||||
Palladium is a **Jupyter Lab-first** environment. Everything starts from a
|
||||
notebook; the Streamlit app and CLI are companions, not prerequisites.
|
||||
notebook; the CLI is a companion, not a prerequisite.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nttdata/palladium.git
|
||||
@@ -73,14 +66,14 @@ Then open **`00_setup.ipynb`** at the repo root. It will:
|
||||
Current target instance: **https://athena.ouranos.helu.ca** (Ouranos sandbox —
|
||||
no production data, safe to experiment).
|
||||
|
||||
From any notebook, setup is one import:
|
||||
From any root-level notebook, the Athena connection is one import (pattern
|
||||
studies are self-contained and never import `core`):
|
||||
|
||||
```python
|
||||
from core.bootstrap import init
|
||||
|
||||
pal = init(study="202512_GenesysCX") # loads .env, connects, imports study
|
||||
pal = init() # loads .env, builds client, tests it
|
||||
pal.client.list_reports()
|
||||
pal.seed_data.BENEFITS
|
||||
```
|
||||
|
||||
### Configuration
|
||||
@@ -92,11 +85,6 @@ writes it for you; to do it by hand:
|
||||
# .env
|
||||
ATHENA_BASE_URL=https://athena.ouranos.helu.ca
|
||||
ATHENA_API_KEY=your-api-key-here
|
||||
# written by the provisioning notebook:
|
||||
PALLADIUM_REPORT_PUBLIC_ID=...
|
||||
PALLADIUM_TOOL_PUBLIC_ID=...
|
||||
PALLADIUM_PROPOSAL_ID=... # or PALLADIUM_ENGAGEMENT_ID — a TEI tool
|
||||
# attaches to exactly one of the two
|
||||
```
|
||||
|
||||
### Verify Connection
|
||||
@@ -129,19 +117,11 @@ Its notebook reproduces the published totals within the PDF's rounding —
|
||||
**NPV $78.7M • ROI 342% • Payback <6 months** — and the verification gate
|
||||
asserts it on every headless run. See the study's README for details.
|
||||
|
||||
The remaining legacy study, `studies/202512_GenesysCX/`, still uses the
|
||||
shared `core/` workflow (`make lab`, provision → push → calculate); it
|
||||
migrates to the pattern next, after which `core/`'s notebook helpers and
|
||||
`app/` retire.
|
||||
|
||||
### Streamlit application (study-agnostic)
|
||||
|
||||
Interactive UI for data entry and version management. Works for any TEI
|
||||
study because field definitions come from Athena at runtime:
|
||||
|
||||
```bash
|
||||
streamlit run app/main.py
|
||||
```
|
||||
`studies/202512_TEI_Genesys_CX_Cloud/` follows the same shape (**NPV $10.8M
|
||||
• ROI 266%**), with one signature input: the Genesys AI Experience token
|
||||
line the published study models at $0, priced live from the client's quote.
|
||||
`studies/202607_CTM_GenesysCX/` is the full multi-notebook reference
|
||||
implementation.
|
||||
|
||||
### CLI
|
||||
|
||||
@@ -171,9 +151,10 @@ python -m palladium export <public_id> -o export.json
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
50 tests cover the API client (mocked HTTP), the financial math, and the
|
||||
export envelope shape. The Amazon Connect seed data is asserted against
|
||||
the published Forrester totals.
|
||||
The root suite covers the API client (mocked HTTP), the financial math, and
|
||||
the export envelope shape; the Amazon Connect verbatim anchor is asserted
|
||||
against the published Forrester totals. Each study additionally carries its
|
||||
own pinned suite (`cd studies/<slug> && pytest`).
|
||||
|
||||
---
|
||||
|
||||
@@ -244,7 +225,7 @@ Three scenarios model uncertainty in adoption and realization
|
||||
```
|
||||
palladium/
|
||||
├── 00_setup.ipynb # ← START HERE: credentials + connection
|
||||
├── Makefile # make setup / lab / app / test
|
||||
├── Makefile # make setup / lab / test
|
||||
├── core/ # Shared, study-agnostic Python package
|
||||
│ ├── bootstrap.py # one-import notebook setup (init, save_credentials)
|
||||
│ ├── tei_client/ # Athena API client
|
||||
@@ -257,25 +238,19 @@ palladium/
|
||||
│ │ └── scenarios.py
|
||||
│ ├── export/
|
||||
│ │ └── report_data.py # JSON envelope for the report pipeline
|
||||
│ ├── notebook_helpers/
|
||||
│ │ ├── tables.py # Pandas dataframe builders
|
||||
│ │ ├── charts.py # Plotly figures
|
||||
│ │ └── display.py # IPython KPI cards, alerts
|
||||
│ └── cli/
|
||||
│ └── main.py # `python -m palladium ...`
|
||||
├── palladium/ # CLI shim (just exposes `python -m palladium`)
|
||||
│ └── __main__.py
|
||||
├── app/ # Streamlit UI — works with any TEI study
|
||||
│ ├── main.py # entry point
|
||||
│ ├── views/ # benefits, costs, summary, versions (NOT `pages/` — avoids Streamlit auto-multipage)
|
||||
│ └── components/ # tables, charts
|
||||
├── template/
|
||||
│ └── MercuryNotebook/ # copy-me pattern scaffold (runnable)
|
||||
├── studies/ # One folder per engagement
|
||||
│ ├── 202512_GenesysCX/ # CX Cloud TEI — legacy shared-core layout
|
||||
│ │ ├── 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
|
||||
├── studies/ # One self-contained folder per engagement
|
||||
│ ├── 202512_TEI_Genesys_CX_Cloud/ # CX Cloud TEI — pattern Variant 4
|
||||
│ │ ├── README.md # NPV $10.8M · ROI 266% + the $0 AI-token line
|
||||
│ │ ├── teicalc/ # self-contained engine (anchor/model/overlay)
|
||||
│ │ ├── notebooks/business_case.ipynb
|
||||
│ │ ├── tests/ · scripts/ · config.toml · pyproject.toml
|
||||
│ │ └── docs/ # Forrester PDF + Genesys token-metering notes
|
||||
│ ├── 202602_TEI_Amazon_Connect/ # Amazon Connect TEI — pattern Variant 4
|
||||
│ │ ├── README.md # NPV $78.7M · ROI 342%, reproduced + gated
|
||||
│ │ ├── teicalc/ # self-contained engine (anchor/model/overlay)
|
||||
@@ -285,7 +260,7 @@ palladium/
|
||||
│ │ └── docs/
|
||||
│ │ └── 202602_TEI Report Amazon Connect.pdf
|
||||
│ └── 202607_CTM_GenesysCX/ # CTM × Genesys study — pattern reference impl
|
||||
├── tests/ # 50 tests for core/
|
||||
├── tests/ # root tests for core/
|
||||
│ ├── test_client.py
|
||||
│ ├── test_calculations.py
|
||||
│ └── test_export.py
|
||||
@@ -384,7 +359,8 @@ The export envelope (`core.export.build_report_data`) includes:
|
||||
|
||||
## Version Management
|
||||
|
||||
Palladium manages version history through both the API and the Streamlit UI:
|
||||
Athena keeps version history for TEI tools, driven through the API
|
||||
(`core.tei_client`: `save_version` / `list_versions` / `get_version`):
|
||||
|
||||
1. **Save Version** — Snapshots current values + summary with a descriptive note
|
||||
2. **View History** — All versions with headline metrics (NPV, ROI)
|
||||
@@ -435,7 +411,6 @@ ruff format .
|
||||
| `requests` | ≥2.31 | HTTP client for Athena API |
|
||||
| `python-dotenv` | ≥1.0 | Environment configuration |
|
||||
| `jupyter` | ≥1.0 | Notebook environment |
|
||||
| `streamlit` | ≥1.30 | Data entry application |
|
||||
| `pandas` | ≥2.0 | Data manipulation |
|
||||
| `plotly` | ≥5.18 | Interactive visualizations |
|
||||
| `numpy` | ≥1.26 | Financial calculations |
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""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
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from core.notebook_helpers import charts as core_charts
|
||||
|
||||
|
||||
def cashflow(yearly_breakdown, *, initial_cost: float = 0.0, key: str = "cashflow") -> None:
|
||||
fig = core_charts.cashflow_chart(yearly_breakdown, initial_cost=initial_cost)
|
||||
st.plotly_chart(fig, width="stretch", key=key)
|
||||
|
||||
|
||||
def benefits_bar(items, *, key: str = "benefits_bar") -> None:
|
||||
fig = core_charts.benefits_bar(items)
|
||||
st.plotly_chart(fig, width="stretch", key=key)
|
||||
|
||||
|
||||
def cost_pie(items, *, key: str = "cost_pie") -> None:
|
||||
fig = core_charts.cost_breakdown_pie(items)
|
||||
st.plotly_chart(fig, width="stretch", key=key)
|
||||
|
||||
|
||||
def benefits_vs_costs_by_year(benefit_items, cost_items, *, key: str = "by_year") -> None:
|
||||
fig = core_charts.benefits_vs_costs_by_year(benefit_items, cost_items)
|
||||
st.plotly_chart(fig, width="stretch", key=key)
|
||||
|
||||
|
||||
def scenario_bars(scenarios, *, key: str = "scenario_bars") -> None:
|
||||
fig = core_charts.scenario_comparison(scenarios)
|
||||
st.plotly_chart(fig, width="stretch", key=key)
|
||||
|
||||
|
||||
def waterfall(values, *, key: str = "waterfall") -> None:
|
||||
fig = core_charts.waterfall(values)
|
||||
st.plotly_chart(fig, width="stretch", key=key)
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Streamlit data-editor wrappers for benefit/cost rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
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]:
|
||||
"""Years 1..N -- taken from analysis_period_years on the report."""
|
||||
return list(range(1, max(int(analysis_years or 3), 1) + 1))
|
||||
|
||||
|
||||
def value_editor(
|
||||
table: str,
|
||||
fields: list[dict],
|
||||
values: list[dict],
|
||||
*,
|
||||
analysis_years: int,
|
||||
key: str,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Render an ``st.data_editor`` for benefit or cost values.
|
||||
|
||||
The editor shows one row per field (filtered to ``table``), with year
|
||||
columns, an ``initial`` column for costs, a risk_adjustment column, and
|
||||
a notes column. Returns the edited DataFrame; the caller is responsible
|
||||
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 = [
|
||||
f
|
||||
for f in fields
|
||||
if f.get("table") == table
|
||||
# Companion "<key>_initial" fields are edited via the Initial column
|
||||
# on their parent cost row, not as standalone rows.
|
||||
and not str(f.get("field_key", "")).endswith("_initial")
|
||||
]
|
||||
fields.sort(key=lambda f: int(f.get("sort_order") or 0))
|
||||
|
||||
by_key = {v.get("field_key"): v for v in values}
|
||||
years = _years_for_table(fields, analysis_years)
|
||||
|
||||
rows: list[dict] = []
|
||||
for f in fields:
|
||||
v = by_key.get(f["field_key"], {}) or {}
|
||||
yv = v.get("year_values") or {}
|
||||
risk_raw = float(v.get("risk_adjustment") or 0.0)
|
||||
row = {
|
||||
"field_key": f["field_key"],
|
||||
"label": f.get("label", f["field_key"]),
|
||||
"category": f.get("category", "") or "",
|
||||
}
|
||||
if table == "costs":
|
||||
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:
|
||||
raw = float(yv.get(str(y)) 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 ""
|
||||
rows.append(row)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
|
||||
_cur_fmt = currency_fmt()
|
||||
_pct_fmt_str = pct_fmt()
|
||||
|
||||
column_config: dict = {
|
||||
"field_key": st.column_config.TextColumn("Key", disabled=True, width="small"),
|
||||
"label": st.column_config.TextColumn("Field", disabled=True),
|
||||
"category": st.column_config.TextColumn("Category", disabled=True, width="small"),
|
||||
"notes": st.column_config.TextColumn("Notes", width="medium"),
|
||||
}
|
||||
|
||||
if _STANDARD_LOCALE:
|
||||
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%)",
|
||||
)
|
||||
if table == "costs":
|
||||
column_config["Initial"] = st.column_config.NumberColumn(
|
||||
"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(
|
||||
df,
|
||||
column_config=column_config,
|
||||
width="stretch",
|
||||
num_rows="fixed",
|
||||
hide_index=True,
|
||||
key=key,
|
||||
)
|
||||
return edited
|
||||
|
||||
|
||||
def df_to_values(df: pd.DataFrame, table: str, analysis_years: int) -> list[dict]:
|
||||
"""Convert an edited DataFrame back to wire-format value rows."""
|
||||
out: list[dict] = []
|
||||
years = list(range(1, max(int(analysis_years or 3), 1) + 1))
|
||||
for _, row in df.iterrows():
|
||||
item: dict = {"field_key": row["field_key"], "table": table}
|
||||
yv = {}
|
||||
for y in years:
|
||||
col = f"Year {y}"
|
||||
if col in df.columns:
|
||||
yv[str(y)] = float(row[col] or 0)
|
||||
if yv:
|
||||
item["year_values"] = yv
|
||||
if table == "costs" and "Initial" in df.columns:
|
||||
item["initial"] = float(row["Initial"] or 0)
|
||||
ra = row.get("risk_adj")
|
||||
if ra is not None and not pd.isna(ra):
|
||||
item["risk_adjustment"] = float(ra)
|
||||
notes = row.get("notes")
|
||||
if isinstance(notes, str) and notes.strip():
|
||||
item["notes"] = notes.strip()
|
||||
out.append(item)
|
||||
return out
|
||||
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
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:]}%"
|
||||
223
app/main.py
223
app/main.py
@@ -1,223 +0,0 @@
|
||||
"""
|
||||
Palladium Streamlit app — TEI data entry, calculation, versioning, export.
|
||||
|
||||
Run from the project root::
|
||||
|
||||
streamlit run app/main.py
|
||||
|
||||
The app picks a TEI tool by ``public_id`` (or creates one from a Report
|
||||
template) and exposes Benefits, Costs, Summary, and Versions pages. It is
|
||||
study-agnostic — the field set is loaded dynamically from Athena based on
|
||||
the linked Report template.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow `streamlit run app/main.py` from project root without `pip install -e .`
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from core.tei_client import AthenaAPIError, TEIClient
|
||||
from app.utils import icon, inject_icons
|
||||
|
||||
st.set_page_config(
|
||||
page_title="Palladium — TEI Calculator",
|
||||
page_icon="🛡️",
|
||||
layout="wide",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@st.cache_resource(show_spinner=False)
|
||||
def get_client() -> TEIClient:
|
||||
return TEIClient()
|
||||
|
||||
|
||||
def _safe_call(fn, *args, **kwargs):
|
||||
"""Run an API call, surfacing errors as Streamlit messages."""
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except AthenaAPIError as e:
|
||||
st.error(f"Athena API error {e.status_code}: {e.detail}")
|
||||
except ValueError as e:
|
||||
st.error(str(e))
|
||||
return None
|
||||
|
||||
|
||||
# CRM lookups, cached briefly so the cascading selects stay snappy.
|
||||
@st.cache_data(ttl=120, show_spinner=False)
|
||||
def _crm_clients(_client: TEIClient) -> list[dict]:
|
||||
try:
|
||||
return _client.list_clients()
|
||||
except AthenaAPIError:
|
||||
return []
|
||||
|
||||
|
||||
@st.cache_data(ttl=120, show_spinner=False)
|
||||
def _crm_proposals(_client: TEIClient, client_id: int) -> list[dict]:
|
||||
try:
|
||||
return _client.proposals_for_client(client_id)
|
||||
except AthenaAPIError:
|
||||
return []
|
||||
|
||||
|
||||
@st.cache_data(ttl=120, show_spinner=False)
|
||||
def _crm_engagements(_client: TEIClient, client_name: str) -> list[dict]:
|
||||
try:
|
||||
return _client.engagements_for_client(client_name)
|
||||
except AthenaAPIError:
|
||||
return []
|
||||
|
||||
|
||||
def sidebar_tool_picker(client: TEIClient) -> dict | None:
|
||||
"""Sidebar: pick an existing TEI tool or create one from a report template."""
|
||||
st.sidebar.markdown(
|
||||
f"{icon('shield-fill')} **Palladium**", unsafe_allow_html=True
|
||||
)
|
||||
st.sidebar.caption("TEI Calculator")
|
||||
|
||||
tools = _safe_call(client.list_tools) or []
|
||||
if tools:
|
||||
labels = {
|
||||
f"{t.get('name', '(unnamed)')} — {t.get('id', '')[:8]}…": t for t in tools
|
||||
}
|
||||
choice = st.sidebar.selectbox("TEI Tool", list(labels.keys()))
|
||||
tool = labels[choice]
|
||||
else:
|
||||
st.sidebar.info("No TEI tools yet. Create one below.")
|
||||
tool = None
|
||||
|
||||
with st.sidebar.expander("Create new tool"):
|
||||
reports = _safe_call(client.list_reports) or []
|
||||
if not reports:
|
||||
st.write("No report templates available.")
|
||||
else:
|
||||
report_labels = {f"{r['name']} ({r['vendor']} {r['version']})": r for r in reports}
|
||||
r_choice = st.selectbox("Report template", list(report_labels.keys()))
|
||||
|
||||
# A TEI tool must attach to a Proposal OR an Engagement.
|
||||
# Cascade: client → proposal/engagement, pulled from the CRM.
|
||||
clients = _crm_clients(client)
|
||||
if not clients:
|
||||
st.warning("No CRM clients found — create one in Athena first.")
|
||||
return tool
|
||||
client_labels = {c["name"]: c for c in clients}
|
||||
c_choice = st.selectbox("Client", list(client_labels.keys()))
|
||||
crm_client = client_labels[c_choice]
|
||||
|
||||
attach_kind = st.radio(
|
||||
"Attach to", ["Proposal", "Engagement"], horizontal=True
|
||||
)
|
||||
proposal_id: int | None = None
|
||||
engagement_id: int | None = None
|
||||
if attach_kind == "Proposal":
|
||||
proposals = _crm_proposals(client, crm_client["id"])
|
||||
if proposals:
|
||||
p_labels = {
|
||||
f"{p.get('name')} ({p.get('status')})": p for p in proposals
|
||||
}
|
||||
p_choice = st.selectbox("Proposal", list(p_labels.keys()))
|
||||
proposal_id = p_labels[p_choice]["id"]
|
||||
else:
|
||||
st.info(
|
||||
f"{crm_client['name']} has no proposals. Create one in "
|
||||
"Athena (or via 00_provision.ipynb) first."
|
||||
)
|
||||
else:
|
||||
engagements = _crm_engagements(client, crm_client["name"])
|
||||
if engagements:
|
||||
e_labels = {
|
||||
f"{e.get('name')} ({e.get('status')})": e for e in engagements
|
||||
}
|
||||
e_choice = st.selectbox("Engagement", list(e_labels.keys()))
|
||||
engagement_id = e_labels[e_choice]["id"]
|
||||
else:
|
||||
st.info(f"{crm_client['name']} has no engagements.")
|
||||
|
||||
default_name = f"{crm_client['name']} — {report_labels[r_choice]['name']}"
|
||||
new_name = st.text_input("Tool name", default_name)
|
||||
if st.button(
|
||||
"Create", disabled=proposal_id is None and engagement_id is None
|
||||
):
|
||||
report = report_labels[r_choice]
|
||||
created = _safe_call(
|
||||
client.create_tool,
|
||||
report_public_id=report["id"],
|
||||
proposal=proposal_id,
|
||||
engagement=engagement_id,
|
||||
name=new_name or None,
|
||||
)
|
||||
if created:
|
||||
st.success(f"Created tool {created.get('id')}")
|
||||
st.cache_data.clear()
|
||||
st.rerun()
|
||||
|
||||
if tool:
|
||||
st.sidebar.divider()
|
||||
_opp = tool.get("opportunity") or {}
|
||||
_client_name = (_opp.get("client") or {}).get("name")
|
||||
if _client_name:
|
||||
st.sidebar.markdown(f"**Client**: {_client_name}")
|
||||
st.sidebar.markdown(f"**Public ID**: `{tool.get('id')}`")
|
||||
st.sidebar.markdown(f"**Status**: {tool.get('status', '?')}")
|
||||
st.sidebar.markdown(f"**Version**: {tool.get('current_version', 0)}")
|
||||
if st.sidebar.button("Recalculate"):
|
||||
_safe_call(client.calculate, tool["id"])
|
||||
st.toast("Recalculated.", icon=None)
|
||||
st.cache_data.clear()
|
||||
return tool
|
||||
|
||||
|
||||
def main() -> None:
|
||||
inject_icons()
|
||||
|
||||
st.markdown(
|
||||
f"<h1 style='margin-bottom:0'>{icon('shield-fill')} Palladium — TEI Calculator</h1>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
try:
|
||||
client = get_client()
|
||||
except ValueError as e:
|
||||
st.error(str(e))
|
||||
st.info("Set ATHENA_BASE_URL and ATHENA_API_KEY in your `.env` file.")
|
||||
st.stop()
|
||||
return
|
||||
|
||||
tool = sidebar_tool_picker(client)
|
||||
|
||||
if tool is None:
|
||||
st.info("Pick or create a TEI tool from the sidebar to begin.")
|
||||
return
|
||||
|
||||
# Tab navigation — matches `app/views/*` modules but kept as tabs so all
|
||||
# views share the chosen tool/state without re-querying.
|
||||
#
|
||||
# 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.views import benefits as benefits_page
|
||||
from app.views import costs as costs_page
|
||||
from app.views import summary as summary_page
|
||||
from app.views import versions as versions_page
|
||||
|
||||
with tabs[0]:
|
||||
summary_page.render(client, tool)
|
||||
with tabs[1]:
|
||||
benefits_page.render(client, tool)
|
||||
with tabs[2]:
|
||||
costs_page.render(client, tool)
|
||||
with tabs[3]:
|
||||
versions_page.render(client, tool)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
45
app/utils.py
45
app/utils.py
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
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>'
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Common helpers shared by the page modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from core.tei_client import AthenaAPIError, TEIClient
|
||||
|
||||
|
||||
def report_meta(client: TEIClient, tool: dict) -> dict:
|
||||
"""Fetch the linked report (handles both nested-object and id-only forms)."""
|
||||
report_obj = tool.get("report")
|
||||
if isinstance(report_obj, dict):
|
||||
return report_obj
|
||||
if isinstance(report_obj, str):
|
||||
try:
|
||||
return client.get_report(report_obj)
|
||||
except AthenaAPIError as e:
|
||||
st.error(f"Failed to load report template: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def safe(fn, *args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except AthenaAPIError as e:
|
||||
st.error(f"Athena API error {e.status_code}: {e.detail}")
|
||||
return None
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Benefits data-entry tab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.components import charts
|
||||
from app.components.tables import df_to_values, value_editor
|
||||
from app.utils import icon
|
||||
|
||||
from app.views._helpers import report_meta, safe
|
||||
from core.tei_client import TEIClient
|
||||
|
||||
|
||||
def render(client: TEIClient, tool: dict) -> None:
|
||||
st.markdown(
|
||||
f"<h2>{icon('graph-up-arrow')} Benefits</h2>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
public_id = tool["id"]
|
||||
report = report_meta(client, tool)
|
||||
analysis_years = int(report.get("analysis_period_years") or 3)
|
||||
|
||||
fields = safe(client.list_fields, report.get("id"), "benefits") or []
|
||||
values = [v for v in safe(client.get_values, public_id) or [] if v.get("table") == "benefits"]
|
||||
|
||||
if not fields:
|
||||
st.info("This report template has no benefit fields defined.")
|
||||
return
|
||||
|
||||
edited = value_editor(
|
||||
"benefits",
|
||||
fields,
|
||||
values,
|
||||
analysis_years=analysis_years,
|
||||
key=f"benefits_editor_{public_id}",
|
||||
)
|
||||
|
||||
col1, col2 = st.columns([1, 4])
|
||||
with col1:
|
||||
if st.button("Save benefits", width="stretch"):
|
||||
|
||||
payload = df_to_values(edited, "benefits", analysis_years)
|
||||
result = safe(client.update_values, public_id, payload)
|
||||
if result is not None:
|
||||
st.success(f"Saved {len(payload)} benefit values.")
|
||||
st.cache_data.clear()
|
||||
with col2:
|
||||
st.caption(
|
||||
"Values are saved as nominal annual amounts. Risk adjustments are "
|
||||
"applied at calculate time. Use the Recalculate button in the "
|
||||
"sidebar after saving to refresh the summary."
|
||||
)
|
||||
|
||||
if values:
|
||||
st.divider()
|
||||
charts.benefits_bar(values, key=f"benefits_tab_bar_{public_id}")
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Costs data-entry tab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.components import charts
|
||||
from app.components.tables import df_to_values, value_editor
|
||||
from app.utils import icon
|
||||
|
||||
from app.views._helpers import report_meta, safe
|
||||
from core.tei_client import TEIClient
|
||||
|
||||
|
||||
def render(client: TEIClient, tool: dict) -> None:
|
||||
st.markdown(
|
||||
f"<h2>{icon('receipt')} Costs</h2>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
public_id = tool["id"]
|
||||
report = report_meta(client, tool)
|
||||
analysis_years = int(report.get("analysis_period_years") or 3)
|
||||
|
||||
fields = safe(client.list_fields, report.get("id"), "costs") or []
|
||||
values = [v for v in safe(client.get_values, public_id) or [] if v.get("table") == "costs"]
|
||||
|
||||
if not fields:
|
||||
st.info("This report template has no cost fields defined.")
|
||||
return
|
||||
|
||||
edited = value_editor(
|
||||
"costs",
|
||||
fields,
|
||||
values,
|
||||
analysis_years=analysis_years,
|
||||
key=f"costs_editor_{public_id}",
|
||||
)
|
||||
|
||||
col1, col2 = st.columns([1, 4])
|
||||
with col1:
|
||||
if st.button("Save costs", width="stretch"):
|
||||
|
||||
payload = df_to_values(edited, "costs", analysis_years)
|
||||
result = safe(client.update_values, public_id, payload)
|
||||
if result is not None:
|
||||
st.success(f"Saved {len(payload)} cost values.")
|
||||
st.cache_data.clear()
|
||||
with col2:
|
||||
st.caption(
|
||||
"The Initial column is undiscounted year-0 spend. Year columns "
|
||||
"are end-of-year cashflows. Costs are risk-adjusted upward "
|
||||
"(higher risk → higher cost)."
|
||||
)
|
||||
|
||||
if values:
|
||||
st.divider()
|
||||
col_pie, col_year = st.columns(2)
|
||||
with col_pie:
|
||||
charts.cost_pie(values, key=f"costs_tab_pie_{public_id}")
|
||||
with col_year:
|
||||
benefit_values = [
|
||||
v
|
||||
for v in safe(client.get_values, public_id) or []
|
||||
if v.get("table") == "benefits"
|
||||
]
|
||||
charts.benefits_vs_costs_by_year(
|
||||
benefit_values, values, key=f"costs_tab_by_year_{public_id}"
|
||||
)
|
||||
@@ -1,202 +0,0 @@
|
||||
"""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",
|
||||
)
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Version history tab — list, diff, save, restore."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.utils import icon
|
||||
|
||||
from app.views._helpers import safe
|
||||
from core.tei_client import TEIClient
|
||||
|
||||
|
||||
def _flatten_values(values: list[dict]) -> dict[str, dict]:
|
||||
"""Index a values list by field_key for easy diffing."""
|
||||
return {v.get("field_key", ""): v for v in values}
|
||||
|
||||
|
||||
def _diff_rows(a: dict[str, dict], b: dict[str, dict]) -> list[dict]:
|
||||
"""Return one row per field with side-by-side year values."""
|
||||
keys = sorted(set(a.keys()) | set(b.keys()))
|
||||
rows: list[dict] = []
|
||||
|
||||
def _years_of(v: dict) -> dict:
|
||||
"""Accept both friendly (year_values) and wire (nested years) shapes."""
|
||||
if isinstance(v.get("year_values"), dict):
|
||||
return {str(k): val for k, val in v["year_values"].items()}
|
||||
if isinstance(v.get("years"), dict):
|
||||
return {
|
||||
str(k): (cell or {}).get("value")
|
||||
for k, cell in v["years"].items()
|
||||
}
|
||||
if v.get("value") is not None:
|
||||
return {"1": v["value"]}
|
||||
return {}
|
||||
|
||||
for k in keys:
|
||||
av = a.get(k, {}) or {}
|
||||
bv = b.get(k, {}) or {}
|
||||
ay = _years_of(av)
|
||||
by = _years_of(bv)
|
||||
years = sorted(set(ay.keys()) | set(by.keys()), key=lambda x: int(x))
|
||||
for y in years:
|
||||
a_val = float(ay.get(y) or 0)
|
||||
b_val = float(by.get(y) or 0)
|
||||
if abs(a_val - b_val) < 1e-9:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"field_key": k,
|
||||
"year": y,
|
||||
"left": a_val,
|
||||
"right": b_val,
|
||||
"delta": b_val - a_val,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def render(client: TEIClient, tool: dict) -> None:
|
||||
st.markdown(
|
||||
f"<h2>{icon('clock-history')} Versions</h2>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
public_id = tool["id"]
|
||||
|
||||
versions = safe(client.list_versions, public_id) or []
|
||||
versions = sorted(
|
||||
versions, key=lambda v: int(v.get("version_number") or 0), reverse=True
|
||||
)
|
||||
|
||||
# Save new version
|
||||
with st.expander("Save current state as a new version", expanded=not versions):
|
||||
note = st.text_area(
|
||||
"Version note",
|
||||
placeholder=(
|
||||
"What changed? E.g. 'CFO confirmed 1.8M contacts/month; "
|
||||
"raised legacy license cost from $160 to $180/agent.'"
|
||||
),
|
||||
)
|
||||
if st.button("Save version", disabled=not note.strip()):
|
||||
result = safe(client.save_version, public_id, note.strip())
|
||||
if result:
|
||||
st.success(
|
||||
f"Saved version {result.get('version_number', '?')}."
|
||||
)
|
||||
st.rerun()
|
||||
|
||||
if not versions:
|
||||
st.info("No versions saved yet.")
|
||||
return
|
||||
|
||||
# Listing
|
||||
st.subheader("History")
|
||||
rows = []
|
||||
for v in versions:
|
||||
snap = v.get("summary_snapshot") or v.get("summary") or {}
|
||||
rows.append(
|
||||
{
|
||||
"Version": v.get("version_number"),
|
||||
"Date": v.get("created_at") or v.get("date"),
|
||||
"NPV": float(snap.get("net_present_value") or snap.get("npv") or 0),
|
||||
"ROI %": float(
|
||||
snap.get("roi_percentage")
|
||||
or snap.get("roi")
|
||||
or snap.get("roi_pct")
|
||||
or 0
|
||||
),
|
||||
"Note": v.get("note", ""),
|
||||
}
|
||||
)
|
||||
st.dataframe(rows, width="stretch", hide_index=True)
|
||||
|
||||
|
||||
# Compare two versions
|
||||
st.subheader("Compare")
|
||||
if len(versions) < 2:
|
||||
st.caption("Save two or more versions to compare.")
|
||||
return
|
||||
labels = {f"v{v['version_number']} — {v.get('note', '')[:40]}": v for v in versions}
|
||||
keys = list(labels.keys())
|
||||
c1, c2 = st.columns(2)
|
||||
with c1:
|
||||
left_label = st.selectbox("Left (older)", keys, index=min(1, len(keys) - 1))
|
||||
with c2:
|
||||
right_label = st.selectbox("Right (newer)", keys, index=0)
|
||||
|
||||
if left_label == right_label:
|
||||
st.caption("Pick two different versions to see a diff.")
|
||||
return
|
||||
|
||||
left = safe(client.get_version, public_id, labels[left_label]["version_number"])
|
||||
right = safe(client.get_version, public_id, labels[right_label]["version_number"])
|
||||
if not (left and right):
|
||||
return
|
||||
|
||||
a_values = left.get("values_snapshot") or left.get("values") or []
|
||||
b_values = right.get("values_snapshot") or right.get("values") or []
|
||||
diff = _diff_rows(_flatten_values(a_values), _flatten_values(b_values))
|
||||
if not diff:
|
||||
st.success("No value differences between these versions.")
|
||||
else:
|
||||
st.dataframe(diff, width="stretch", hide_index=True)
|
||||
|
||||
@@ -7,10 +7,11 @@ From *any* notebook in the repo (root, ``studies/<slug>/notebooks/``, …)::
|
||||
pal = init() # loads .env, builds client, tests it
|
||||
pal.client.list_reports()
|
||||
|
||||
or, for a study notebook::
|
||||
|
||||
pal = init(study="202602_AmazonConnect")
|
||||
pal.config.STUDY_SLUG, pal.seed_data.BENEFITS
|
||||
(Pattern studies under ``studies/`` are self-contained — they carry their
|
||||
own engine and venv and never import ``core``. The legacy ``study=``
|
||||
parameter loaded a study's ``config.py``/``seed_data.py``; those modules
|
||||
were retired with the study migrations, so ``init()`` is now purely the
|
||||
Athena connection bootstrap.)
|
||||
|
||||
If ``core`` itself can't be imported (fresh kernel, notebook cwd deep in the
|
||||
tree), put this two-liner first — it is the only path juggling left anywhere::
|
||||
|
||||
@@ -138,7 +138,7 @@ def build_report_data(
|
||||
include_scenarios: if True, locally compute conservative / moderate /
|
||||
aggressive summaries and attach them under ``scenarios``.
|
||||
study_slug: optional human-friendly study identifier (e.g.
|
||||
``"202602_AmazonConnect"``) — written into ``metadata``.
|
||||
``"202602_TEI_Amazon_Connect"``) — written into ``metadata``.
|
||||
|
||||
Returns:
|
||||
A dict with keys::
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Notebook helpers — pandas tables, plotly charts, IPython display."""
|
||||
|
||||
from core.notebook_helpers import charts, display, tables
|
||||
|
||||
__all__ = ["charts", "display", "tables"]
|
||||
@@ -1,315 +0,0 @@
|
||||
"""
|
||||
Plotly charts for TEI analyses.
|
||||
|
||||
Each function returns a ``plotly.graph_objects.Figure`` so callers can
|
||||
``.show()`` (notebook), pass to ``st.plotly_chart`` (Streamlit), or write to
|
||||
HTML / image. No styling is hard-coded beyond a neutral default palette.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
import plotly.graph_objects as go
|
||||
|
||||
PALETTE = {
|
||||
"benefits": "#2E7D32", # green
|
||||
"costs": "#C62828", # red
|
||||
"net_positive": "#1565C0", # blue
|
||||
"net_negative": "#C62828",
|
||||
"cumulative": "#616161", # grey
|
||||
}
|
||||
|
||||
#: Visual theme — override per study/client with :func:`apply_theme`.
|
||||
#: Hex colours; fonts are CSS font-family strings.
|
||||
THEME = {
|
||||
"heading_font": "Helvetica Neue, Arial, sans-serif",
|
||||
"body_font": "Helvetica, Arial, sans-serif",
|
||||
"font_color": "#1F2937",
|
||||
# Circle-chart slice colours a–j, used in order.
|
||||
"pie_colors": [
|
||||
"#1565C0", # a
|
||||
"#2E7D32", # b
|
||||
"#C62828", # c
|
||||
"#F9A825", # d
|
||||
"#6A1B9A", # e
|
||||
"#00838F", # f
|
||||
"#EF6C00", # g
|
||||
"#5D4037", # h
|
||||
"#37474F", # i
|
||||
"#AD1457", # j
|
||||
],
|
||||
"bar_green": "#2E7D32",
|
||||
"bar_red": "#C62828",
|
||||
}
|
||||
|
||||
|
||||
def apply_theme(**overrides) -> dict:
|
||||
"""
|
||||
Override theme values for all charts in this session.
|
||||
|
||||
Accepts any THEME key. ``pie_colors`` may be a list (used in order) or
|
||||
a dict keyed ``"a"``–``"j"`` (sorted alphabetically). Returns the
|
||||
active theme. Example::
|
||||
|
||||
from core.notebook_helpers import charts
|
||||
charts.apply_theme(
|
||||
heading_font="Georgia, serif",
|
||||
font_color="#102A43",
|
||||
pie_colors={"a": "#1565C0", "b": "#2E7D32"},
|
||||
bar_green="#1B5E20",
|
||||
bar_red="#B71C1C",
|
||||
)
|
||||
"""
|
||||
for key, value in overrides.items():
|
||||
if key not in THEME:
|
||||
raise KeyError(
|
||||
f"Unknown theme key {key!r}. Valid keys: {sorted(THEME)}"
|
||||
)
|
||||
if key == "pie_colors" and isinstance(value, dict):
|
||||
value = [value[k] for k in sorted(value)]
|
||||
THEME[key] = value
|
||||
return THEME
|
||||
|
||||
|
||||
def _themed(fig: go.Figure) -> go.Figure:
|
||||
"""Apply theme fonts/colours to a figure's layout."""
|
||||
fig.update_layout(
|
||||
font={"family": THEME["body_font"], "color": THEME["font_color"]},
|
||||
title_font={
|
||||
"family": THEME["heading_font"],
|
||||
"color": THEME["font_color"],
|
||||
},
|
||||
legend_font={"family": THEME["body_font"], "color": THEME["font_color"]},
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
def cashflow_chart(
|
||||
yearly_breakdown: list[dict],
|
||||
*,
|
||||
title: str = "Cash Flow Analysis (Risk-Adjusted)",
|
||||
initial_cost: float = 0.0,
|
||||
) -> go.Figure:
|
||||
"""
|
||||
Stacked bars of benefits & costs by year + cumulative net line.
|
||||
|
||||
Mirrors the chart on page 25 of the Forrester Amazon Connect TEI study.
|
||||
"""
|
||||
if not yearly_breakdown:
|
||||
return go.Figure(layout={"title": title})
|
||||
|
||||
years = ["Initial"] + [f"Year {row['year']}" for row in yearly_breakdown]
|
||||
benefits = [0.0] + [float(row.get("benefits", 0)) for row in yearly_breakdown]
|
||||
costs = [-float(initial_cost)] + [
|
||||
-float(row.get("costs", 0)) for row in yearly_breakdown
|
||||
]
|
||||
# cumulative_net assumes initial cost has already been deducted
|
||||
cumulative = [-float(initial_cost)] + [
|
||||
float(row.get("cumulative_net", 0)) for row in yearly_breakdown
|
||||
]
|
||||
|
||||
fig = go.Figure()
|
||||
fig.add_bar(
|
||||
name="Total benefits",
|
||||
x=years,
|
||||
y=benefits,
|
||||
marker_color=THEME["bar_green"],
|
||||
)
|
||||
fig.add_bar(
|
||||
name="Total costs",
|
||||
x=years,
|
||||
y=costs,
|
||||
marker_color=THEME["bar_red"],
|
||||
)
|
||||
fig.add_scatter(
|
||||
name="Cumulative net benefits",
|
||||
x=years,
|
||||
y=cumulative,
|
||||
mode="lines+markers",
|
||||
line={"color": PALETTE["cumulative"], "width": 3},
|
||||
)
|
||||
fig.update_layout(
|
||||
title=title,
|
||||
barmode="relative",
|
||||
yaxis_tickformat="$,.0f",
|
||||
legend={"orientation": "h", "y": -0.15},
|
||||
margin={"l": 40, "r": 20, "t": 60, "b": 40},
|
||||
)
|
||||
return _themed(fig)
|
||||
|
||||
|
||||
def benefits_bar(items: list[dict], *, title: str = "Benefits (Three-Year)") -> go.Figure:
|
||||
"""Horizontal bars of risk-adjusted three-year totals per benefit."""
|
||||
labels: list[str] = []
|
||||
totals: list[float] = []
|
||||
for it in items:
|
||||
rf = float(it.get("risk_adjustment") or 0.0)
|
||||
yv = it.get("year_values") or {}
|
||||
ra_total = sum(float(v or 0) * (1.0 - rf) for v in yv.values())
|
||||
labels.append(it.get("label", "") or it.get("field_key", ""))
|
||||
totals.append(ra_total)
|
||||
|
||||
fig = go.Figure(
|
||||
go.Bar(
|
||||
x=totals,
|
||||
y=labels,
|
||||
orientation="h",
|
||||
marker_color=THEME["bar_green"],
|
||||
text=[f"${t/1_000_000:,.1f}M" for t in totals],
|
||||
textposition="auto",
|
||||
)
|
||||
)
|
||||
fig.update_layout(
|
||||
title=title,
|
||||
xaxis_tickformat="$,.0f",
|
||||
yaxis={"autorange": "reversed"},
|
||||
margin={"l": 40, "r": 20, "t": 60, "b": 40},
|
||||
)
|
||||
return _themed(fig)
|
||||
|
||||
|
||||
def cost_breakdown_pie(
|
||||
items: list[dict], *, title: str = "Cost Breakdown (Three-Year, Risk-Adjusted)"
|
||||
) -> go.Figure:
|
||||
"""Pie chart of risk-adjusted costs by category/label."""
|
||||
labels: list[str] = []
|
||||
values: list[float] = []
|
||||
for it in items:
|
||||
rf = float(it.get("risk_adjustment") or 0.0)
|
||||
yv = it.get("year_values") or {}
|
||||
initial = float(it.get("initial") or 0.0)
|
||||
ra_total = (
|
||||
initial * (1.0 + rf)
|
||||
+ sum(float(v or 0) * (1.0 + rf) for v in yv.values())
|
||||
)
|
||||
labels.append(it.get("label", "") or it.get("field_key", ""))
|
||||
values.append(ra_total)
|
||||
|
||||
fig = go.Figure(go.Pie(labels=labels, values=values, hole=0.35,
|
||||
marker={"colors": THEME["pie_colors"]}))
|
||||
fig.update_layout(title=title, margin={"l": 40, "r": 20, "t": 60, "b": 40})
|
||||
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:
|
||||
"""Grouped bars comparing NPV and Costs PV across scenarios."""
|
||||
keys: list[str] = list(scenarios.keys())
|
||||
if not keys:
|
||||
return go.Figure()
|
||||
benefits = [float(scenarios[k].get("total_benefits_pv") or 0) for k in keys]
|
||||
costs = [float(scenarios[k].get("total_costs_pv") or 0) for k in keys]
|
||||
npvs = [float(scenarios[k].get("npv") or 0) for k in keys]
|
||||
|
||||
fig = go.Figure()
|
||||
fig.add_bar(name="Benefits PV", x=keys, y=benefits, marker_color=THEME["bar_green"])
|
||||
fig.add_bar(name="Costs PV", x=keys, y=costs, marker_color=THEME["bar_red"])
|
||||
fig.add_bar(name="NPV", x=keys, y=npvs, marker_color=PALETTE["net_positive"])
|
||||
fig.update_layout(
|
||||
title="Scenario Comparison",
|
||||
barmode="group",
|
||||
yaxis_tickformat="$,.0f",
|
||||
legend={"orientation": "h", "y": -0.15},
|
||||
)
|
||||
return _themed(fig)
|
||||
|
||||
|
||||
def cumulative_benefits_chart(
|
||||
yearly_breakdown: list[dict],
|
||||
*,
|
||||
title: str = "Cumulative Net Benefits",
|
||||
) -> go.Figure:
|
||||
"""Single-line cumulative net benefits trajectory."""
|
||||
if not yearly_breakdown:
|
||||
return go.Figure(layout={"title": title})
|
||||
years = [f"Year {row['year']}" for row in yearly_breakdown]
|
||||
cumulative = [float(row.get("cumulative_net", 0)) for row in yearly_breakdown]
|
||||
fig = go.Figure(
|
||||
go.Scatter(
|
||||
x=years,
|
||||
y=cumulative,
|
||||
mode="lines+markers",
|
||||
fill="tozeroy",
|
||||
line={"color": PALETTE["net_positive"], "width": 3},
|
||||
)
|
||||
)
|
||||
fig.update_layout(title=title, yaxis_tickformat="$,.0f")
|
||||
return _themed(fig)
|
||||
|
||||
|
||||
def waterfall(values: Iterable[tuple[str, float]], *, title: str = "TEI Waterfall") -> go.Figure:
|
||||
"""
|
||||
Generic waterfall (pass tuples of (label, value)).
|
||||
|
||||
Used by 03_business_case to show: Benefits PV → Costs PV → NPV.
|
||||
"""
|
||||
labels, amounts = zip(*values, strict=True) if values else ([], [])
|
||||
measures = ["relative"] * (len(labels) - 1) + ["total"] if labels else []
|
||||
fig = go.Figure(
|
||||
go.Waterfall(
|
||||
x=list(labels),
|
||||
y=list(amounts),
|
||||
measure=measures,
|
||||
text=[f"${v/1_000_000:,.1f}M" for v in amounts],
|
||||
textposition="outside",
|
||||
increasing={"marker": {"color": THEME["bar_green"]}},
|
||||
decreasing={"marker": {"color": THEME["bar_red"]}},
|
||||
totals={"marker": {"color": PALETTE["net_positive"]}},
|
||||
)
|
||||
)
|
||||
fig.update_layout(title=title, yaxis_tickformat="$,.0f")
|
||||
return _themed(fig)
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
IPython display helpers — KPI cards, formatted summary blocks, alerts.
|
||||
|
||||
Functions are notebook-safe: they fall back to plain ``print`` when running
|
||||
outside Jupyter / when IPython is not available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try: # pragma: no cover – IPython is a soft dep
|
||||
from IPython.display import HTML, display
|
||||
|
||||
_IPY = True
|
||||
except Exception: # pragma: no cover
|
||||
_IPY = False
|
||||
|
||||
|
||||
def _money(value: Any, default: str = "—") -> str:
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if abs(v) >= 1_000_000_000:
|
||||
return f"${v/1_000_000_000:,.1f}B"
|
||||
if abs(v) >= 1_000_000:
|
||||
return f"${v/1_000_000:,.1f}M"
|
||||
if abs(v) >= 1_000:
|
||||
return f"${v/1_000:,.1f}K"
|
||||
return f"${v:,.0f}"
|
||||
|
||||
|
||||
def _pct(value: Any, default: str = "—") -> str:
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return f"{v:,.0f}%"
|
||||
|
||||
|
||||
def _months(value: Any, default: str = "N/A") -> str:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if v < 6:
|
||||
return f"<6 months ({v:.1f})"
|
||||
return f"{v:.1f} months"
|
||||
|
||||
|
||||
def kpi_cards(summary: dict, *, title: str | None = None) -> Any:
|
||||
"""
|
||||
Render a row of KPI cards (NPV, ROI, Payback, Benefits PV).
|
||||
|
||||
In notebooks, returns/displays inline HTML. Outside IPython, prints a
|
||||
plain text version.
|
||||
"""
|
||||
npv = _money(summary.get("npv"))
|
||||
roi = _pct(summary.get("roi") or summary.get("roi_pct"))
|
||||
payback = _months(summary.get("payback_months"))
|
||||
benefits_pv = _money(summary.get("total_benefits_pv"))
|
||||
costs_pv = _money(summary.get("total_costs_pv"))
|
||||
|
||||
if not _IPY: # pragma: no cover
|
||||
print(title or "TEI Summary")
|
||||
print(f" NPV: {npv} ROI: {roi} Payback: {payback}")
|
||||
print(f" Benefits PV: {benefits_pv} Costs PV: {costs_pv}")
|
||||
return None
|
||||
|
||||
title_html = (
|
||||
f'<div style="font-size:1.1em;font-weight:600;margin-bottom:6px;color:#444;">'
|
||||
f"{title}</div>"
|
||||
if title
|
||||
else ""
|
||||
)
|
||||
card_style = (
|
||||
"flex:1;min-width:140px;padding:14px 18px;margin:4px;border-radius:8px;"
|
||||
"background:#f7f9fc;border:1px solid #e3e8ee;"
|
||||
)
|
||||
label_style = "font-size:0.78em;color:#6b7480;text-transform:uppercase;letter-spacing:0.04em;"
|
||||
value_style = "font-size:1.6em;font-weight:600;color:#1a2540;margin-top:4px;"
|
||||
|
||||
cards = [
|
||||
("NPV", npv),
|
||||
("ROI", roi),
|
||||
("Payback", payback),
|
||||
("Benefits PV", benefits_pv),
|
||||
("Costs PV", costs_pv),
|
||||
]
|
||||
cards_html = "".join(
|
||||
f'<div style="{card_style}">'
|
||||
f'<div style="{label_style}">{label}</div>'
|
||||
f'<div style="{value_style}">{value}</div>'
|
||||
f"</div>"
|
||||
for label, value in cards
|
||||
)
|
||||
html = (
|
||||
f'<div>{title_html}'
|
||||
f'<div style="display:flex;flex-wrap:wrap;align-items:stretch;">{cards_html}</div>'
|
||||
f"</div>"
|
||||
)
|
||||
return display(HTML(html))
|
||||
|
||||
|
||||
def summary_panel(summary: dict, *, title: str = "TEI Financial Summary") -> None:
|
||||
"""Plain-text bordered summary block (mirrors the PDF Cash Flow Analysis)."""
|
||||
width = 60
|
||||
print("═" * width)
|
||||
print(f" {title}")
|
||||
print("═" * width)
|
||||
print(f" Benefits PV : {_money(summary.get('total_benefits_pv')):>20}")
|
||||
print(f" Costs PV : {_money(summary.get('total_costs_pv')):>20}")
|
||||
print("─" * width)
|
||||
print(f" NPV : {_money(summary.get('npv')):>20}")
|
||||
roi_val = summary.get("roi") or summary.get("roi_pct")
|
||||
print(f" ROI : {_pct(roi_val):>20}")
|
||||
print(f" Payback : {_months(summary.get('payback_months')):>20}")
|
||||
print("═" * width)
|
||||
|
||||
|
||||
def alert(text: str, kind: str = "info") -> Any:
|
||||
"""Coloured alert box for notebooks ('info', 'success', 'warning', 'error')."""
|
||||
colors = {
|
||||
"info": ("#0277bd", "#e1f5fe"),
|
||||
"success": ("#2e7d32", "#e8f5e9"),
|
||||
"warning": ("#ef6c00", "#fff3e0"),
|
||||
"error": ("#c62828", "#ffebee"),
|
||||
}
|
||||
fg, bg = colors.get(kind, colors["info"])
|
||||
if not _IPY: # pragma: no cover
|
||||
print(f"[{kind.upper()}] {text}")
|
||||
return None
|
||||
html = (
|
||||
f'<div style="padding:10px 14px;border-left:4px solid {fg};'
|
||||
f'background:{bg};color:#1a1a1a;border-radius:4px;margin:6px 0;">'
|
||||
f"{text}</div>"
|
||||
)
|
||||
return display(HTML(html))
|
||||
@@ -1,127 +0,0 @@
|
||||
"""
|
||||
Pandas dataframe builders for benefit / cost / summary tables.
|
||||
|
||||
Each builder accepts the friendly value-row dicts returned by
|
||||
``core.tei_client.TEIClient.get_values`` and returns a
|
||||
nicely-formatted DataFrame for display in notebooks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from core.calculations import risk_adjust_benefit, risk_adjust_cost
|
||||
|
||||
|
||||
def _years_in_data(items: Iterable[dict]) -> list[int]:
|
||||
years: set[int] = set()
|
||||
for it in items:
|
||||
for k in (it.get("year_values") or {}):
|
||||
try:
|
||||
years.add(int(k))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return sorted(years)
|
||||
|
||||
|
||||
def benefits_table(items: list[dict]) -> pd.DataFrame:
|
||||
"""Tidy benefits dataframe with one row per benefit, year columns, totals."""
|
||||
if not items:
|
||||
return pd.DataFrame(
|
||||
columns=["field_key", "label", "category", "risk_adjustment"]
|
||||
)
|
||||
years = _years_in_data(items)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for it in items:
|
||||
rf = float(it.get("risk_adjustment") or 0.0)
|
||||
yv = it.get("year_values") or {}
|
||||
row = {
|
||||
"field_key": it.get("field_key", ""),
|
||||
"label": it.get("label", "") or it.get("field_key", ""),
|
||||
"category": it.get("category", ""),
|
||||
"risk_adjustment": rf,
|
||||
}
|
||||
nominal_total = 0.0
|
||||
ra_total = 0.0
|
||||
for y in years:
|
||||
v = float(yv.get(str(y)) or 0.0)
|
||||
ra = risk_adjust_benefit(v, rf)
|
||||
row[f"Year {y}"] = v
|
||||
row[f"Year {y} (RA)"] = ra
|
||||
nominal_total += v
|
||||
ra_total += ra
|
||||
row["Total"] = nominal_total
|
||||
row["Total (RA)"] = ra_total
|
||||
rows.append(row)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def costs_table(items: list[dict]) -> pd.DataFrame:
|
||||
"""Tidy costs dataframe — adds an Initial column when present."""
|
||||
if not items:
|
||||
return pd.DataFrame(
|
||||
columns=["field_key", "label", "category", "risk_adjustment", "Initial"]
|
||||
)
|
||||
years = _years_in_data(items)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for it in items:
|
||||
rf = float(it.get("risk_adjustment") or 0.0)
|
||||
yv = it.get("year_values") or {}
|
||||
initial = float(it.get("initial") or 0.0)
|
||||
row = {
|
||||
"field_key": it.get("field_key", ""),
|
||||
"label": it.get("label", "") or it.get("field_key", ""),
|
||||
"category": it.get("category", ""),
|
||||
"risk_adjustment": rf,
|
||||
"Initial": initial,
|
||||
"Initial (RA)": risk_adjust_cost(initial, rf),
|
||||
}
|
||||
nominal_total = initial
|
||||
ra_total = risk_adjust_cost(initial, rf)
|
||||
for y in years:
|
||||
v = float(yv.get(str(y)) or 0.0)
|
||||
ra = risk_adjust_cost(v, rf)
|
||||
row[f"Year {y}"] = v
|
||||
row[f"Year {y} (RA)"] = ra
|
||||
nominal_total += v
|
||||
ra_total += ra
|
||||
row["Total"] = nominal_total
|
||||
row["Total (RA)"] = ra_total
|
||||
rows.append(row)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def summary_table(summary: dict) -> pd.DataFrame:
|
||||
"""Single-row summary dataframe of headline KPIs."""
|
||||
pb = summary.get("payback_months")
|
||||
pb_str = f"{float(pb):.1f} months" if pb not in (None, "") else "N/A"
|
||||
data = {
|
||||
"NPV": [float(summary.get("npv") or 0)],
|
||||
"ROI %": [float(summary.get("roi") or summary.get("roi_pct") or 0)],
|
||||
"Payback": [pb_str],
|
||||
"Benefits PV": [float(summary.get("total_benefits_pv") or 0)],
|
||||
"Costs PV": [float(summary.get("total_costs_pv") or 0)],
|
||||
"Discount rate": [float(summary.get("discount_rate") or 0)],
|
||||
"Analysis years": [int(summary.get("analysis_years") or 0)],
|
||||
}
|
||||
return pd.DataFrame(data)
|
||||
|
||||
|
||||
def cashflow_table(summary: dict) -> pd.DataFrame:
|
||||
"""Per-year cashflow dataframe from a summary's ``yearly_breakdown``."""
|
||||
yb = summary.get("yearly_breakdown") or []
|
||||
if not yb:
|
||||
return pd.DataFrame(columns=["Year", "Benefits", "Costs", "Net", "Cumulative"])
|
||||
df = pd.DataFrame(yb)
|
||||
rename = {
|
||||
"year": "Year",
|
||||
"benefits": "Benefits",
|
||||
"costs": "Costs",
|
||||
"net": "Net",
|
||||
"cumulative_net": "Cumulative",
|
||||
}
|
||||
df = df.rename(columns=rename)
|
||||
return df
|
||||
@@ -20,7 +20,6 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
notebooks = ["jupyter>=1.0", "ipython>=8.0"]
|
||||
app = ["streamlit>=1.30"]
|
||||
dev = ["pytest>=7.4", "ruff>=0.1"]
|
||||
|
||||
[project.scripts]
|
||||
@@ -28,7 +27,7 @@ palladium = "core.cli.main:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["core*", "palladium*"]
|
||||
exclude = ["tests*", "studies*", "app*", "docs*"]
|
||||
exclude = ["tests*", "studies*", "docs*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -46,4 +45,3 @@ ignore = ["E501"] # line length handled by formatter
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"studies/*/notebooks/*.ipynb" = ["E402"]
|
||||
"tests/*" = ["F401"]
|
||||
"app/main.py" = ["E402"] # sys.path bootstrap before app imports
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
requests>=2.31
|
||||
python-dotenv>=1.0
|
||||
jupyter>=1.0
|
||||
streamlit>=1.30
|
||||
pandas>=2.0
|
||||
plotly>=5.18
|
||||
numpy>=1.26
|
||||
|
||||
BIN
studies/202512_GenesysCX/.DS_Store
vendored
BIN
studies/202512_GenesysCX/.DS_Store
vendored
Binary file not shown.
@@ -1,51 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,38 +0,0 @@
|
||||
"""
|
||||
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")
|
||||
@@ -1,934 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,382 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
115
studies/202512_TEI_Genesys_CX_Cloud/README.md
Normal file
115
studies/202512_TEI_Genesys_CX_Cloud/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 202512 — Genesys CX Cloud TEI
|
||||
|
||||
Self-contained reproduction of Forrester's *The Total Economic Impact™ Of
|
||||
CX Cloud — Cost Savings And Business Benefits Enabled By Genesys And
|
||||
Salesforce* (December 2025, commissioned by Genesys and Salesforce), built
|
||||
on the [Mercury Notebook Deliverable Pattern](../../docs/Mercury_Notebook_Pattern_V1-00.md)
|
||||
as **Variant 4 — TEI composite reproduction**: Forrester's composite
|
||||
organization is the never-edited verbatim anchor, the in-notebook gate
|
||||
proves the engine reproduces the published totals, and 🟡 client drivers
|
||||
rescale the composite live.
|
||||
|
||||
## Source
|
||||
|
||||
The full Forrester study is at
|
||||
[`docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf`](docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf);
|
||||
[`docs/Genesys-Token-Metering.md`](docs/Genesys-Token-Metering.md) covers
|
||||
the AI Experience token pricing the study omits.
|
||||
|
||||
Published composite totals (3-yr risk-adjusted PV @ 10%), reproduced by
|
||||
`teicalc` to within $2:
|
||||
|
||||
| Metric | Published | Engine |
|
||||
|---|---|---|
|
||||
| Benefits PV | **$14,840,638** | $14,840,637 |
|
||||
| Costs PV | **$4,057,170** | $4,057,170 |
|
||||
| NPV | **$10,783,468** | $10,783,466 |
|
||||
| ROI | **266%** | 265.79% |
|
||||
| Payback | *not headlined* | 3.3 months |
|
||||
|
||||
## Composite organization (the verbatim anchor 🟢)
|
||||
|
||||
* Global supply company, $2.5B revenue, 10,000 employees
|
||||
* 600 CX agents (400 concurrent licenses)
|
||||
* 80,000 weekly interactions @ 12 minutes
|
||||
* Self-service completion 15% → 25%
|
||||
|
||||
## The $0 AI line (🔴)
|
||||
|
||||
The published study models **zero Genesys AI Experience token
|
||||
consumption**, even though the self-service (B), agent-efficiency (C), and
|
||||
agent-assist (D) benefits all depend on token-billed AI capabilities. The
|
||||
anchor keeps the $0 verbatim so the reproduction matches the PDF; the
|
||||
notebook exposes `ai_tokens_annual` as a direct 🔴 sidebar input — price it
|
||||
from the Genesys quote and the case re-derives live. (This critique is what
|
||||
grew into the CTM token-calculator engagement, `../202607_CTM_GenesysCX/`.)
|
||||
|
||||
## Client overlay (🟡)
|
||||
|
||||
A first-order linear rescale — "the composite at your size", not "your
|
||||
TEI". The composite's trajectory is flat (Y2 = Y3), so there is no growth
|
||||
re-base:
|
||||
|
||||
| Row | Driver | Confidence |
|
||||
|---|---|---|
|
||||
| Legacy retirement · CX Cloud licenses | agents | 🟡 |
|
||||
| Self-service savings · agent efficiency | interactions | 🟡 |
|
||||
| Agent-assist sales | revenue | 🟡 |
|
||||
| Implementation · ongoing management | fixed | 🟡 project-based |
|
||||
| Genesys AI tokens | direct $/yr input | 🔴 $0 until quoted |
|
||||
|
||||
## Study quirks (documented in the anchor, verbatim)
|
||||
|
||||
- p.14 prints the implementation initial as $1,304,600; the 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.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
202512_TEI_Genesys_CX_Cloud/
|
||||
├── teicalc/ ← ALL math (stdlib-only) — notebooks hold none
|
||||
│ ├── anchor.py ← Forrester's tables, VERBATIM, never edited
|
||||
│ ├── model.py ← NPV/ROI/payback, risk adjustment, compute_summary
|
||||
│ ├── overlay.py ← ClientDrivers + driver map + the AI-token input
|
||||
│ ├── scenarios.py ← conservative / moderate / aggressive
|
||||
│ └── staging.py ← on_stage()/backstage() (Mercury vs nbconvert)
|
||||
├── notebooks/business_case.ipynb ← THE deliverable
|
||||
├── scripts/export_report.py
|
||||
├── tests/ ← hand-checked pinned acceptance numbers
|
||||
├── config.toml ← Mercury theme (NTT DATA brand)
|
||||
├── pyproject.toml ← full toolchain as core deps — no requirements.txt
|
||||
├── docs/ ← the Forrester PDF + token-metering notes
|
||||
└── exports/ ← generated .html/.md; gitignored
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
| Task | Command |
|
||||
|---|---|
|
||||
| Tests | `pytest` |
|
||||
| Serve (the stage) | `mercury --working-dir notebooks/` (run from this project root so `config.toml` loads) |
|
||||
| Analyst view (backstage) | `jupyter lab` |
|
||||
| Headless check | `jupyter nbconvert --to notebook --execute --inplace notebooks/business_case.ipynb` |
|
||||
| Export for LLMs | `python scripts/export_report.py` |
|
||||
|
||||
The data appendix (markdown tables + JSON model state) rides inside
|
||||
`exports/business_case.md` — the payload for the Athena study-repository
|
||||
roadmap.
|
||||
|
||||
## History
|
||||
|
||||
This study previously ran on the shared `core/` package with an
|
||||
Athena-workflow notebook chain (provision → push → calculate) and
|
||||
study-scoped `PALLADIUM_GENESYSCX_*` env keys. That workflow — including
|
||||
the `ATHENA_EXPECTED` reconciliation for Athena's discount-initial-as-
|
||||
Year-1 convention — was retired when the study migrated to the pattern
|
||||
(git history preserves it); the engine reproduces the published totals
|
||||
locally under Forrester's own conventions, pinned in `tests/`.
|
||||
81
studies/202512_TEI_Genesys_CX_Cloud/config.toml
Normal file
81
studies/202512_TEI_Genesys_CX_Cloud/config.toml
Normal file
@@ -0,0 +1,81 @@
|
||||
# Mercury app-shell theme — NTT DATA brand (light), modern surfaces.
|
||||
# See docs/brand.md for the source palette.
|
||||
#
|
||||
# Loaded from the directory where you launch `mercury` (this project root);
|
||||
# restart the server to apply changes. Only keys in mercury/config.py
|
||||
# CSS_VARIABLE_MAP emit a CSS variable — anything else in DEFAULT_THEME is
|
||||
# either derived or component-baked (e.g. success/warning/danger, slider
|
||||
# track, widget bg) and silently no-ops here. Omitted keys are derived
|
||||
# from the ones below.
|
||||
|
||||
[main]
|
||||
title = "Genesys CX Cloud TEI — Business Case"
|
||||
favicon_emoji = "📊"
|
||||
footer = "Genesys CX Cloud TEI study (Forrester, Dec 2025)"
|
||||
notebooks_button_label = "Analyses"
|
||||
|
||||
[welcome]
|
||||
header = "Genesys CX Cloud TEI"
|
||||
message = """
|
||||
Interactive reproduction of Forrester's *Total Economic Impact™ Of CX
|
||||
Cloud* composite ($10.8M NPV · 266% ROI). The published study is the
|
||||
verbatim anchor — including the AI-token line it models at $0; tune the
|
||||
🟡 client drivers live (and price the tokens), then export the
|
||||
personalized report source with `python scripts/export_report.py`.
|
||||
"""
|
||||
|
||||
[theme]
|
||||
# ── Type — Georgia headings, Arial body. Both web-safe system fonts,
|
||||
# so no font_url / network fetch. Georgia ships only normal+bold, so
|
||||
# heading weight is 700 (the default 800 would render as faux-bold). ──
|
||||
font_family = "Arial, 'Helvetica Neue', Helvetica, sans-serif"
|
||||
heading_font_family = "Georgia, 'Times New Roman', Times, serif"
|
||||
font_size = "15px"
|
||||
font_weight = "normal"
|
||||
heading_font_weight = "700"
|
||||
|
||||
# ── Text — NTT ink scale ──
|
||||
text_color = "#2e404d" # body
|
||||
muted_text_color = "#586671" # captions / secondary
|
||||
|
||||
# ── Surfaces — white content floating on a soft neutral canvas (depth).
|
||||
# For a strictly-white page instead, set background_color = "#ffffff". ──
|
||||
background_color = "#f4f5f6" # outer page
|
||||
content_background_color = "#ffffff" # notebook column
|
||||
surface_color = "#ffffff"
|
||||
card_background_color = "#f8f8f8" # brand card
|
||||
border_color = "#d5d9db" # brand border
|
||||
border_radius = "10px" # modern rounding
|
||||
|
||||
# ── Accents — Future Blue. primary_color also drives the Run button + focus. ──
|
||||
primary_color = "#0072bc"
|
||||
accent_color = "#0072bc"
|
||||
focus_border_color = "#0072bc"
|
||||
hover_background_color = "#eef5fb" # light blue tint
|
||||
selected_background_color = "#dcecfa"
|
||||
|
||||
# ── Sidebar — clean white, hairline divider ──
|
||||
sidebar_background_color = "#ffffff"
|
||||
sidebar_text_color = "#2e404d"
|
||||
sidebar_title_color = "#151d2c"
|
||||
sidebar_shadow = "1px 0 0 #d5d9db"
|
||||
|
||||
# ── Top bar — deep NTT navy (brand heading-primary) ──
|
||||
topbar_background_color = "#151d2c"
|
||||
topbar_text_color = "#ffffff"
|
||||
topbar_border_color = "rgba(255,255,255,0.08)"
|
||||
|
||||
# ── Footer ──
|
||||
footer_background_color = "#ffffff"
|
||||
footer_text_color = "#586671"
|
||||
footer_border_color = "#d5d9db"
|
||||
|
||||
# ── Run button — subtle brand-blue gradient (else derives from primary) ──
|
||||
run_button_background = "linear-gradient(180deg, #0087dc 0%, #0072bc 100%)"
|
||||
run_button_background_hover = "linear-gradient(180deg, #1a93e6 0%, #0079c8 100%)"
|
||||
run_button_text_color = "#ffffff"
|
||||
|
||||
# ── Depth — soft, navy-tinted shadows (modern) ──
|
||||
shadow_sm = "0 1px 2px rgba(21,29,44,0.05)"
|
||||
shadow_md = "0 6px 18px rgba(21,29,44,0.08)"
|
||||
shadow_lg = "0 16px 40px rgba(21,29,44,0.10)"
|
||||
7133
studies/202512_TEI_Genesys_CX_Cloud/notebooks/business_case.ipynb
Normal file
7133
studies/202512_TEI_Genesys_CX_Cloud/notebooks/business_case.ipynb
Normal file
File diff suppressed because one or more lines are too long
36
studies/202512_TEI_Genesys_CX_Cloud/pyproject.toml
Normal file
36
studies/202512_TEI_Genesys_CX_Cloud/pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "teicalc"
|
||||
version = "0.1.0"
|
||||
description = "Genesys CX Cloud TEI (Forrester, Dec 2025) — composite reproduction + client overlay incl. the AI-token line"
|
||||
requires-python = ">=3.10"
|
||||
# The notebook is the deliverable (served with Mercury, exported via
|
||||
# nbconvert, tables via tabulate) — the whole toolchain is a required
|
||||
# runtime dependency, not an extra. `pip install -e .` must be enough.
|
||||
dependencies = [
|
||||
"pandas>=2.0",
|
||||
"plotly>=5.18",
|
||||
"openpyxl>=3.1",
|
||||
"mercury>=3.2",
|
||||
"jupyterlab>=4.0",
|
||||
"ipywidgets>=8.0",
|
||||
"nbconvert>=7",
|
||||
"tabulate>=0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=7.4", "mypy>=1.8"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["teicalc*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
packages = ["teicalc"]
|
||||
47
studies/202512_TEI_Genesys_CX_Cloud/scripts/export_report.py
Normal file
47
studies/202512_TEI_Genesys_CX_Cloud/scripts/export_report.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Export the deliverable notebooks as LLM-readable report sources.
|
||||
|
||||
Executes each notebook fresh (widget defaults — or whatever defaults you edit in),
|
||||
then writes both formats to exports/:
|
||||
|
||||
exports/<notebook>.html — human-reviewable, tables render
|
||||
exports/<notebook>.md — leanest LLM input
|
||||
|
||||
Plotly figures export as JavaScript an LLM cannot read; each notebook's
|
||||
machine-readable appendix section carries every number behind them.
|
||||
|
||||
Run from the project root: python scripts/export_report.py [name-filter]
|
||||
An optional argument exports only notebooks whose filename contains it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NOTEBOOKS = [
|
||||
ROOT / "notebooks" / "business_case.ipynb",
|
||||
]
|
||||
EXPORTS = ROOT / "exports"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
picked = [nb for nb in NOTEBOOKS
|
||||
if len(sys.argv) < 2 or sys.argv[1] in nb.name]
|
||||
if not picked:
|
||||
sys.exit(f"no notebook matches {sys.argv[1]!r}")
|
||||
EXPORTS.mkdir(exist_ok=True)
|
||||
for nb in picked:
|
||||
for fmt in ("html", "markdown"):
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "nbconvert", "--execute",
|
||||
"--to", fmt, "--output-dir", str(EXPORTS), str(nb)],
|
||||
check=True, cwd=ROOT,
|
||||
)
|
||||
for p in sorted(EXPORTS.iterdir()):
|
||||
if p.suffix in (".html", ".md"):
|
||||
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
56
studies/202512_TEI_Genesys_CX_Cloud/teicalc/__init__.py
Normal file
56
studies/202512_TEI_Genesys_CX_Cloud/teicalc/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
teicalc — self-contained engine for the Genesys CX Cloud TEI study
|
||||
(Forrester, December 2025). Mercury Notebook Pattern, Variant 4:
|
||||
verbatim composite anchor → published-totals gate → client overlay
|
||||
(including the AI-token line the published study left at $0).
|
||||
"""
|
||||
|
||||
from .anchor import ASSUMPTIONS, BENEFITS_VERBATIM, COSTS_VERBATIM, PUBLISHED
|
||||
from .model import (
|
||||
X_LABELS,
|
||||
YEAR_INDEX,
|
||||
YEARS,
|
||||
benefits_by_year,
|
||||
by_calendar,
|
||||
compute_summary,
|
||||
costs_by_year,
|
||||
discount_factor,
|
||||
html_money,
|
||||
initial_costs,
|
||||
money,
|
||||
month_label,
|
||||
npv,
|
||||
payback_label,
|
||||
payback_months,
|
||||
payback_years,
|
||||
present_value,
|
||||
risk_adjust_benefit,
|
||||
risk_adjust_cost,
|
||||
risk_adjusted_rows,
|
||||
roi_pct,
|
||||
)
|
||||
from .overlay import (
|
||||
BENEFIT_DRIVERS,
|
||||
COMPOSITE,
|
||||
COST_DRIVERS,
|
||||
ClientDrivers,
|
||||
overlay_rows,
|
||||
scale_factor,
|
||||
)
|
||||
from .scenarios import SCENARIOS, apply_scenario
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"ASSUMPTIONS", "BENEFITS_VERBATIM", "COSTS_VERBATIM", "PUBLISHED",
|
||||
"YEARS", "YEAR_INDEX", "X_LABELS",
|
||||
"by_calendar", "month_label",
|
||||
"discount_factor", "present_value", "npv", "roi_pct",
|
||||
"payback_years", "payback_months", "payback_label",
|
||||
"risk_adjust_benefit", "risk_adjust_cost", "risk_adjusted_rows",
|
||||
"benefits_by_year", "costs_by_year", "initial_costs",
|
||||
"compute_summary", "money", "html_money",
|
||||
"ClientDrivers", "COMPOSITE", "BENEFIT_DRIVERS", "COST_DRIVERS",
|
||||
"scale_factor", "overlay_rows",
|
||||
"SCENARIOS", "apply_scenario",
|
||||
]
|
||||
@@ -1,45 +1,38 @@
|
||||
"""
|
||||
Seed dataset for the Genesys CX Cloud TEI (Forrester, Dec 2025).
|
||||
The verbatim anchor — Forrester *The Total Economic Impact™ Of CX Cloud —
|
||||
Cost Savings And Business Benefits Enabled By Genesys And Salesforce*
|
||||
(December 2025, commissioned by Genesys and Salesforce).
|
||||
|
||||
"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.
|
||||
VERBATIM, do not edit. These are Forrester's published composite-organization
|
||||
tables and financial summary, transplanted unchanged from the study PDF
|
||||
(``docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf``). Client personalization
|
||||
lives in :mod:`teicalc.overlay`; scenario stress lives in
|
||||
:mod:`teicalc.scenarios` — both deep-copy, neither mutates this record.
|
||||
|
||||
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).
|
||||
Rows keep Forrester's own year-index keys (``"1"``/``"2"``/``"3"``);
|
||||
:mod:`teicalc.model` maps them to calendar years (2026–2028). Values are
|
||||
*nominal* (pre-risk-adjustment); the risk factor is stored per row and
|
||||
applied by the model (benefits ×(1−rf), costs ×(1+rf), per the TEI
|
||||
methodology).
|
||||
|
||||
Published headline (3-yr risk-adjusted, 10% discount)::
|
||||
Two study-specific footnotes, preserved from the source review:
|
||||
|
||||
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.
|
||||
* The published 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.
|
||||
* ``genesys_ai_tokens`` is **not in the published study** — Forrester
|
||||
modeled $0 AI consumption even though benefits B (self-service uplift),
|
||||
C (agent efficiency), and D (agent assist upsell) all depend on AI
|
||||
capabilities that Genesys bills via AI Experience tokens. The row is
|
||||
anchored at $0 so the reproduction matches the published totals; client
|
||||
cases price it via the overlay's ``ai_tokens_annual`` driver.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: 3-year nominal benefit cashflows. Risk adjustment stored separately.
|
||||
BENEFITS: list[dict] = [
|
||||
#: 3-year nominal benefit cashflows — 🟢 published.
|
||||
BENEFITS_VERBATIM: list[dict] = [
|
||||
{
|
||||
"field_key": "legacy_retirement",
|
||||
"table": "benefits",
|
||||
@@ -98,9 +91,10 @@ BENEFITS: list[dict] = [
|
||||
]
|
||||
|
||||
|
||||
#: Costs are nominal; push × (1 + risk_adjustment). "initial" is the
|
||||
#: Year-0 component (companion non-annual field in Athena).
|
||||
COSTS: list[dict] = [
|
||||
#: Costs include an ``initial`` (year-0, undiscounted) component for
|
||||
#: implementation. Cost risk adjustments are applied *upward*. 🟢 published
|
||||
#: (except the ``genesys_ai_tokens`` line — see the module docstring).
|
||||
COSTS_VERBATIM: list[dict] = [
|
||||
{
|
||||
"field_key": "cx_cloud_licenses",
|
||||
"table": "costs",
|
||||
@@ -158,16 +152,17 @@ COSTS: list[dict] = [
|
||||
"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 "
|
||||
"Experience tokens. Anchored 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."
|
||||
"cost from the Genesys quote (the overlay's ai_tokens_annual "
|
||||
"driver) and document the quote details (token volume, unit "
|
||||
"price, tier)."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
#: Composite-organization drivers — for scaling to a specific client.
|
||||
#: Composite-organization drivers — 🟢 published (PDF "Composite Organization").
|
||||
ASSUMPTIONS: dict = {
|
||||
"annual_revenue": 2_500_000_000,
|
||||
"employees": 10_000,
|
||||
@@ -188,44 +183,15 @@ ASSUMPTIONS: dict = {
|
||||
}
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# 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%).
|
||||
#: The PDF's Financial Summary — the gate's reproduction target. 🟢 published.
|
||||
#: The engine reproduces these to within $2 (Forrester's own rounding).
|
||||
#: Forrester does not headline a payback for this study; the engine computes
|
||||
#: 3.3 months from the cash-flow table.
|
||||
PUBLISHED: dict = {
|
||||
"total_benefits_pv": 14_840_638,
|
||||
"total_costs_pv": 4_057_170,
|
||||
"net_present_value": 10_783_468,
|
||||
"roi_percentage": 266,
|
||||
"benefits_pv": 14_840_638,
|
||||
"costs_pv": 4_057_170,
|
||||
"npv": 10_783_468,
|
||||
"roi_pct": 266,
|
||||
"discount_rate": 0.10,
|
||||
"analysis_years": 3,
|
||||
}
|
||||
|
||||
#: 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
|
||||
267
studies/202512_TEI_Genesys_CX_Cloud/teicalc/model.py
Normal file
267
studies/202512_TEI_Genesys_CX_Cloud/teicalc/model.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Finance engine — the single source of truth for every number in the notebook.
|
||||
|
||||
Transplanted from the retired shared ``core/calculations`` and
|
||||
``core/export/report_data.py`` so the study is self-contained (Mercury
|
||||
Notebook Pattern, Required §2/§7). Conventions match the Forrester TEI
|
||||
methodology:
|
||||
|
||||
* The *Initial* investment is **not** discounted — it occurs at time zero.
|
||||
* Year-N cash flows are discounted at the end of the year:
|
||||
``PV = CF_n / (1 + r) ** n``.
|
||||
* Benefits are risk-adjusted *down* (``×(1−rf)``), costs *up* (``×(1+rf)``).
|
||||
* Payback runs on risk-adjusted **undiscounted** flows (the PDF's
|
||||
"<6 months" uses the Cash Flow Analysis table's nominal RA rows).
|
||||
|
||||
Everything this module returns for display is keyed by **calendar year**
|
||||
(Forrester Year 1/2/3 → 2026/2027/2028); ``initial`` stays a Year-0 scalar
|
||||
and never appears inside a ``*_by_year`` dict.
|
||||
|
||||
This module is stdlib-only on purpose — the repo-root test suite imports it
|
||||
without the study's venv.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterable, Sequence
|
||||
from copy import deepcopy
|
||||
|
||||
# ── Timeline ─────────────────────────────────────────────────────────
|
||||
|
||||
YEARS: list[int] = [2026, 2027, 2028] # Forrester Year 1/2/3; window opens Jan 2026
|
||||
YEAR_INDEX: dict[int, int] = {y: i for i, y in enumerate(YEARS, start=1)}
|
||||
X_LABELS: list[str] = ["Initial"] + [str(y) for y in YEARS]
|
||||
|
||||
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
|
||||
|
||||
def month_label(m: int) -> str:
|
||||
"""Calendar label for a 1-indexed month from Jan of YEARS[0]."""
|
||||
return f"{_MONTHS[(m - 1) % 12]} {YEARS[0] + (m - 1) // 12}"
|
||||
|
||||
|
||||
def by_calendar(year_values: dict[str, float]) -> dict[int, float]:
|
||||
"""Map Forrester's ``{"1": v, …}`` year-index keys to calendar years."""
|
||||
return {YEARS[int(k) - 1]: float(v or 0) for k, v in year_values.items()}
|
||||
|
||||
|
||||
# ── Discounting primitives ───────────────────────────────────────────
|
||||
|
||||
|
||||
def discount_factor(year_index: int, discount_rate: float) -> float:
|
||||
"""``1 / (1 + r) ** n``. Year 0 → 1.0 (no discount)."""
|
||||
if year_index < 0:
|
||||
raise ValueError("year_index must be >= 0")
|
||||
return 1.0 / ((1.0 + discount_rate) ** year_index)
|
||||
|
||||
|
||||
def present_value(amount: float, year_index: int, discount_rate: float) -> float:
|
||||
"""Discount ``amount`` from end-of-year ``year_index`` to present."""
|
||||
return amount * discount_factor(year_index, discount_rate)
|
||||
|
||||
|
||||
def npv(cashflows: Iterable[float], discount_rate: float,
|
||||
initial: float = 0.0) -> float:
|
||||
"""``initial + Σ CF_n / (1 + r)^n`` — initial undiscounted (TEI)."""
|
||||
return initial + sum(
|
||||
present_value(float(cf), n, discount_rate)
|
||||
for n, cf in enumerate(cashflows, start=1)
|
||||
)
|
||||
|
||||
|
||||
def roi_pct(benefits_pv: float, costs_pv: float) -> float:
|
||||
"""``(Benefits − Costs) / Costs`` as a percentage; 0 when costs ≤ 0."""
|
||||
if costs_pv <= 0:
|
||||
return 0.0
|
||||
return (benefits_pv - costs_pv) / costs_pv * 100.0
|
||||
|
||||
|
||||
# ── Payback ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def payback_years(initial_cost: float,
|
||||
yearly_net: Sequence[float]) -> float | None:
|
||||
"""
|
||||
Years until cumulative net benefits cover the initial cost, with linear
|
||||
interpolation inside the crossing year. ``None`` if never reached.
|
||||
"""
|
||||
remaining = float(initial_cost)
|
||||
if remaining <= 0:
|
||||
return 0.0
|
||||
for i, cf in enumerate(yearly_net):
|
||||
cf = float(cf)
|
||||
if cf <= 0:
|
||||
remaining += -cf # a net-loss year widens the gap
|
||||
continue
|
||||
if cf >= remaining:
|
||||
return i + remaining / cf
|
||||
remaining -= cf
|
||||
return None
|
||||
|
||||
|
||||
def payback_months(initial_cost: float,
|
||||
yearly_net: Sequence[float]) -> float | None:
|
||||
"""Same as :func:`payback_years`, in months."""
|
||||
yrs = payback_years(initial_cost, yearly_net)
|
||||
return yrs * 12.0 if yrs is not None else None
|
||||
|
||||
|
||||
def payback_label(months: float | None) -> str:
|
||||
"""Human label: ``"0.7 months (~Jan 2026)"`` / ``"immediate"`` / ``"beyond 2028"``."""
|
||||
if months is None:
|
||||
return f"beyond {YEARS[-1]}"
|
||||
if months <= 0:
|
||||
return "immediate"
|
||||
return f"{months:.1f} months (~{month_label(max(1, math.ceil(months)))})"
|
||||
|
||||
|
||||
# ── Risk adjustment (TEI: benefits down, costs up) ───────────────────
|
||||
|
||||
|
||||
def risk_adjust_benefit(amount: float, risk_factor: float) -> float:
|
||||
"""``amount × (1 − rf)``, rf clamped to [0, 1]."""
|
||||
rf = max(0.0, min(1.0, float(risk_factor)))
|
||||
return amount * (1.0 - rf)
|
||||
|
||||
|
||||
def risk_adjust_cost(amount: float, risk_factor: float) -> float:
|
||||
"""``amount × (1 + rf)``, rf clamped to [0, 1]."""
|
||||
rf = max(0.0, min(1.0, float(risk_factor)))
|
||||
return amount * (1.0 + rf)
|
||||
|
||||
|
||||
def risk_adjusted_rows(rows: list[dict], table: str) -> list[dict]:
|
||||
"""Deep-copied rows with the per-row risk factor applied to every value."""
|
||||
adjust = risk_adjust_benefit if table == "benefits" else risk_adjust_cost
|
||||
out: list[dict] = []
|
||||
for raw in rows:
|
||||
row = deepcopy(raw)
|
||||
rf = float(row.get("risk_adjustment") or 0.0)
|
||||
row["year_values"] = {
|
||||
k: adjust(float(v or 0), rf) for k, v in row["year_values"].items()
|
||||
}
|
||||
if row.get("initial"):
|
||||
# Only costs carry an initial; TEI adjusts it upward like the years.
|
||||
row["initial"] = risk_adjust_cost(float(row["initial"]), rf) \
|
||||
if table == "costs" else float(row["initial"])
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
# ── Aggregation (calendar-keyed) ─────────────────────────────────────
|
||||
|
||||
|
||||
def _totals_by_year(ra_rows: list[dict]) -> dict[int, float]:
|
||||
totals = {y: 0.0 for y in YEARS}
|
||||
for row in ra_rows:
|
||||
for y, v in by_calendar(row["year_values"]).items():
|
||||
totals[y] += v
|
||||
return totals
|
||||
|
||||
|
||||
def benefits_by_year(rows: list[dict]) -> dict[int, float]:
|
||||
"""Risk-adjusted benefit totals per calendar year."""
|
||||
return _totals_by_year(risk_adjusted_rows(rows, "benefits"))
|
||||
|
||||
|
||||
def costs_by_year(rows: list[dict]) -> dict[int, float]:
|
||||
"""Risk-adjusted cost totals per calendar year (excludes ``initial``)."""
|
||||
return _totals_by_year(risk_adjusted_rows(rows, "costs"))
|
||||
|
||||
|
||||
def initial_costs(rows: list[dict]) -> float:
|
||||
"""Risk-adjusted Year-0 outlay (undiscounted)."""
|
||||
return sum(
|
||||
float(row.get("initial") or 0)
|
||||
for row in risk_adjusted_rows(rows, "costs")
|
||||
)
|
||||
|
||||
|
||||
# ── Composite summary ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def compute_summary(benefits: list[dict], costs: list[dict],
|
||||
discount_rate: float = 0.10) -> dict:
|
||||
"""
|
||||
The full business-case readout for one set of value rows.
|
||||
|
||||
Returns KPIs (``benefits_pv``/``costs_pv``/``npv``/``roi_pct``/
|
||||
``payback_months``/``payback_label``/``initial_costs``/nominal totals),
|
||||
calendar-keyed schedules (``benefits_by_year``/``costs_by_year``/
|
||||
``net_by_year``/``cumulative_net_by_year`` — cumulative subtracts the
|
||||
initial outlay), and a per-row breakdown under ``rows``.
|
||||
"""
|
||||
ben_ra = risk_adjusted_rows(benefits, "benefits")
|
||||
cost_ra = risk_adjusted_rows(costs, "costs")
|
||||
|
||||
ben_by = _totals_by_year(ben_ra)
|
||||
cost_by = _totals_by_year(cost_ra)
|
||||
initial = sum(float(r.get("initial") or 0) for r in cost_ra)
|
||||
|
||||
benefits_pv = npv([ben_by[y] for y in YEARS], discount_rate)
|
||||
costs_pv = npv([cost_by[y] for y in YEARS], discount_rate, initial=initial)
|
||||
|
||||
net_by = {y: ben_by[y] - cost_by[y] for y in YEARS}
|
||||
cum, cum_by = -initial, {}
|
||||
for y in YEARS:
|
||||
cum += net_by[y]
|
||||
cum_by[y] = cum
|
||||
|
||||
pb_months = payback_months(initial, [net_by[y] for y in YEARS])
|
||||
|
||||
def _row_breakdown(ra_rows: list[dict], table: str) -> list[dict]:
|
||||
out = []
|
||||
for row in ra_rows:
|
||||
ra_by = by_calendar(row["year_values"])
|
||||
init_ra = float(row.get("initial") or 0)
|
||||
entry = {
|
||||
"field_key": row["field_key"],
|
||||
"label": row["label"],
|
||||
"category": row["category"],
|
||||
"risk_adjustment": row["risk_adjustment"],
|
||||
"ra_by_year": ra_by,
|
||||
"three_yr_ra": sum(ra_by.values()),
|
||||
"pv": npv([ra_by[y] for y in YEARS], discount_rate,
|
||||
initial=init_ra if table == "costs" else 0.0),
|
||||
}
|
||||
if table == "costs":
|
||||
entry["initial_ra"] = init_ra
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
return {
|
||||
"discount_rate": discount_rate,
|
||||
"benefits_pv": benefits_pv,
|
||||
"costs_pv": costs_pv,
|
||||
"npv": benefits_pv - costs_pv,
|
||||
"roi_pct": roi_pct(benefits_pv, costs_pv),
|
||||
"payback_months": pb_months,
|
||||
"payback_label": payback_label(pb_months),
|
||||
"initial_costs": initial,
|
||||
"benefits_nominal": sum(ben_by.values()),
|
||||
"costs_nominal": sum(cost_by.values()) + initial,
|
||||
"benefits_by_year": ben_by,
|
||||
"costs_by_year": cost_by,
|
||||
"net_by_year": net_by,
|
||||
"cumulative_net_by_year": cum_by,
|
||||
"rows": {
|
||||
"benefits": _row_breakdown(ben_ra, "benefits"),
|
||||
"costs": _row_breakdown(cost_ra, "costs"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Display helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def money(v: float) -> str:
|
||||
sign, a = ("-" if v < 0 else ""), abs(v)
|
||||
return f"{sign}${a/1e6:,.1f}M" if a >= 1e6 else f"{sign}${a/1e3:,.0f}K"
|
||||
|
||||
|
||||
def html_money(v: float) -> str:
|
||||
"""Plotly text with two or more bare ``$`` triggers MathJax math mode —
|
||||
annotations holding several amounts must use the HTML entity instead."""
|
||||
return money(v).replace("$", "$")
|
||||
107
studies/202512_TEI_Genesys_CX_Cloud/teicalc/overlay.py
Normal file
107
studies/202512_TEI_Genesys_CX_Cloud/teicalc/overlay.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Client overlay — Variant 4's personalization layer.
|
||||
|
||||
The verbatim anchor is Forrester's *composite organization* ($2.5B revenue,
|
||||
600 CX agents, 80k weekly interactions). This module rescales that composite
|
||||
to a client's size: a 🟡 **first-order linear rescale**, answering "what does
|
||||
the composite look like at your scale?", not "what is your TEI?".
|
||||
|
||||
Each verbatim row is tied to the driver that dominates its derivation in the
|
||||
PDF (see ``BENEFIT_DRIVERS``/``COST_DRIVERS``); rows scale linearly with
|
||||
their driver, project-based costs stay fixed. This composite's trajectory is
|
||||
flat (Y2 = Y3), so there is no growth re-base; linear scaling preserves the
|
||||
legacy-retirement ramp shape.
|
||||
|
||||
The one non-ratio driver is ``ai_tokens_annual``: the published study models
|
||||
**$0** Genesys AI Experience token consumption (see the anchor's footnote),
|
||||
so a client case prices that line directly — the negotiated annual figure
|
||||
from the Genesys quote replaces the row's year values outright.
|
||||
|
||||
``overlay_rows(COMPOSITE)`` is the identity — it reproduces the verbatim
|
||||
numbers exactly (tokens included, at $0), so headless widget defaults form
|
||||
the published-study reproduction the gate expects. The anchor is never
|
||||
mutated: every function deep-copies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .anchor import ASSUMPTIONS, BENEFITS_VERBATIM, COSTS_VERBATIM
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClientDrivers:
|
||||
"""Client inputs; defaults are the Forrester composite (identity overlay)."""
|
||||
|
||||
agents_fte: int = ASSUMPTIONS["agents_fte"] # 600 (400 concurrent licenses)
|
||||
weekly_interactions: int = ASSUMPTIONS["weekly_interactions"] # 80,000 @ 12 min
|
||||
annual_revenue: float = ASSUMPTIONS["annual_revenue"] # $2.5B
|
||||
ai_tokens_annual: float = 0.0 # 🔴 published study models $0 AI consumption
|
||||
discount_rate: float = ASSUMPTIONS["discount_rate"] # 0.10
|
||||
|
||||
|
||||
COMPOSITE = ClientDrivers()
|
||||
|
||||
|
||||
#: 🟡 Which driver each verbatim row scales with, per its PDF derivation.
|
||||
BENEFIT_DRIVERS: dict[str, str] = {
|
||||
"legacy_retirement": "agents", # seat-scoped legacy platform costs
|
||||
"self_service_savings": "interactions", # deflected volume → FTEs
|
||||
"agent_efficiency": "interactions", # MTTR saving × handled volume
|
||||
"agent_assist_sales": "revenue", # 20% of revenue × lift × margin
|
||||
}
|
||||
COST_DRIVERS: dict[str, str] = {
|
||||
"cx_cloud_licenses": "agents", # 400 concurrent of 600 agents
|
||||
"implementation": "fixed", # 10-week project — does not scale
|
||||
"ongoing_management": "fixed", # small fixed team
|
||||
"genesys_ai_tokens": "ai_tokens", # 🔴 direct annual input, not a ratio
|
||||
}
|
||||
|
||||
|
||||
def scale_factor(driver: str, d: ClientDrivers) -> float:
|
||||
"""Linear size ratio vs the composite for one ratio-driver kind."""
|
||||
if driver == "agents":
|
||||
return d.agents_fte / ASSUMPTIONS["agents_fte"]
|
||||
if driver == "interactions":
|
||||
return d.weekly_interactions / ASSUMPTIONS["weekly_interactions"]
|
||||
if driver == "revenue":
|
||||
return d.annual_revenue / ASSUMPTIONS["annual_revenue"]
|
||||
if driver == "fixed":
|
||||
return 1.0
|
||||
raise KeyError(f"Unknown driver: {driver!r}")
|
||||
|
||||
|
||||
def overlay_rows(d: ClientDrivers = COMPOSITE) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Deep-copied (benefits, costs) rows rescaled to the client's drivers.
|
||||
|
||||
Ratio-driven rows: ``year_values[n] ×= scale_factor(driver)`` (and
|
||||
``initial`` likewise). Fixed rows are untouched. The ``ai_tokens`` row
|
||||
takes ``d.ai_tokens_annual`` as each year's value directly — the
|
||||
negotiated quote figure, not a rescale of the anchor's $0.
|
||||
Risk factors, labels, and notes are unchanged everywhere.
|
||||
"""
|
||||
|
||||
def _apply(rows: list[dict], drivers: dict[str, str]) -> list[dict]:
|
||||
out = []
|
||||
for raw in rows:
|
||||
row = deepcopy(raw)
|
||||
driver = drivers[row["field_key"]]
|
||||
if driver == "ai_tokens":
|
||||
row["year_values"] = {
|
||||
k: float(d.ai_tokens_annual) for k in row["year_values"]
|
||||
}
|
||||
elif driver != "fixed":
|
||||
s = scale_factor(driver, d)
|
||||
row["year_values"] = {
|
||||
k: float(v) * s for k, v in row["year_values"].items()
|
||||
}
|
||||
if row.get("initial"):
|
||||
row["initial"] = float(row["initial"]) * s
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
return (_apply(BENEFITS_VERBATIM, BENEFIT_DRIVERS),
|
||||
_apply(COSTS_VERBATIM, COST_DRIVERS))
|
||||
67
studies/202512_TEI_Genesys_CX_Cloud/teicalc/scenarios.py
Normal file
67
studies/202512_TEI_Genesys_CX_Cloud/teicalc/scenarios.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Scenario stress — transplanted from the retired shared ``core/calculations/scenarios.py``
|
||||
with identical semantics.
|
||||
|
||||
Forrester TEI risk-adjusts benefits *down* and costs *up*; scenarios stress
|
||||
both levers:
|
||||
|
||||
* ``adoption`` scales nominal values (``year_values`` and ``initial``).
|
||||
* ``risk_delta`` is *added* to a benefit's risk factor and *subtracted*
|
||||
from a cost's (conservative = more uncertainty on benefits, less padding
|
||||
on costs), then clamped to [0, 1].
|
||||
|
||||
``"moderate"`` is the identity — the headless default reproduces the
|
||||
published study. Note the counterintuitive corollary: the conservative
|
||||
scenario *lowers* costs PV, because 80% adoption shrinks consumption-priced
|
||||
usage and the clamp caps cost padding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
SCENARIOS: dict[str, dict[str, float]] = {
|
||||
"conservative": {"adoption": 0.80, "risk_delta": 0.10},
|
||||
"moderate": {"adoption": 1.00, "risk_delta": 0.00},
|
||||
"aggressive": {"adoption": 1.15, "risk_delta": -0.05},
|
||||
}
|
||||
|
||||
|
||||
def apply_scenario(
|
||||
items: list[dict],
|
||||
scenario: str = "moderate",
|
||||
*,
|
||||
multipliers: dict[str, dict[str, float]] | None = None,
|
||||
table: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Deep-copied value rows with the scenario applied; inputs are not mutated.
|
||||
|
||||
Each row needs ``year_values`` (year-string → float), optionally
|
||||
``initial`` and ``risk_adjustment``, and a ``table`` of ``"benefits"``
|
||||
or ``"costs"`` (or pass ``table=`` to force one) — the table decides the
|
||||
sign of ``risk_delta``.
|
||||
"""
|
||||
cfg = (multipliers or SCENARIOS).get(scenario)
|
||||
if cfg is None:
|
||||
raise KeyError(f"Unknown scenario: {scenario!r}")
|
||||
adoption = float(cfg.get("adoption", 1.0))
|
||||
risk_delta = float(cfg.get("risk_delta", 0.0))
|
||||
|
||||
out: list[dict] = []
|
||||
for raw in items:
|
||||
item = deepcopy(raw)
|
||||
item_table = item.get("table") or table or "benefits"
|
||||
item["table"] = item_table
|
||||
|
||||
item["year_values"] = {
|
||||
k: float(v) * adoption for k, v in item["year_values"].items()
|
||||
}
|
||||
if item.get("initial") is not None:
|
||||
item["initial"] = float(item["initial"]) * adoption
|
||||
|
||||
ra = float(item.get("risk_adjustment") or 0.0)
|
||||
new_ra = ra + risk_delta if item_table == "benefits" else ra - risk_delta
|
||||
item["risk_adjustment"] = max(0.0, min(1.0, new_ra))
|
||||
out.append(item)
|
||||
return out
|
||||
29
studies/202512_TEI_Genesys_CX_Cloud/teicalc/staging.py
Normal file
29
studies/202512_TEI_Genesys_CX_Cloud/teicalc/staging.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Stage vs backstage — is this notebook render stakeholder-facing?
|
||||
|
||||
The Mercury CLI (``mercury --working-dir …``) exports ``MERCURY_CONFIG_DIR``
|
||||
into the server process so the widget library can locate ``config.toml``
|
||||
(see ``mercury/config.py``); every kernel that server spawns inherits it.
|
||||
JupyterLab and nbconvert kernels don't have it. That makes the variable a
|
||||
reliable signal for "the audience is looking" (the stage) versus an
|
||||
analyst session or a headless export run (backstage).
|
||||
|
||||
Diagnostics routed through :func:`backstage` stay visible in JupyterLab
|
||||
and land in the nbconvert exports (where the machine-readable appendix
|
||||
must appear for LLM consumption) but never render in the Mercury app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def on_stage() -> bool:
|
||||
"""True when running under the Mercury app (stakeholder-facing)."""
|
||||
return os.getenv("MERCURY_CONFIG_DIR") is not None
|
||||
|
||||
|
||||
def backstage(*args, **kwargs) -> None:
|
||||
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
|
||||
if not on_stage():
|
||||
print(*args, **kwargs)
|
||||
7
studies/202512_TEI_Genesys_CX_Cloud/tests/conftest.py
Normal file
7
studies/202512_TEI_Genesys_CX_Cloud/tests/conftest.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Make teicalc importable even without the study venv active (the normal
|
||||
setup is ``pip install -e ".[dev]"`` into the study-local ``.venv/``)."""
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||
104
studies/202512_TEI_Genesys_CX_Cloud/tests/test_anchor.py
Normal file
104
studies/202512_TEI_Genesys_CX_Cloud/tests/test_anchor.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""The verbatim anchor is Forrester's published record — pinned value by
|
||||
value, and proven immutable under every engine code path."""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from teicalc import (
|
||||
ASSUMPTIONS,
|
||||
BENEFITS_VERBATIM,
|
||||
COMPOSITE,
|
||||
COSTS_VERBATIM,
|
||||
PUBLISHED,
|
||||
ClientDrivers,
|
||||
apply_scenario,
|
||||
compute_summary,
|
||||
overlay_rows,
|
||||
)
|
||||
|
||||
|
||||
def _row(rows, key):
|
||||
return next(r for r in rows if r["field_key"] == key)
|
||||
|
||||
|
||||
def test_benefit_rows_verbatim():
|
||||
assert [r["field_key"] for r in BENEFITS_VERBATIM] == [
|
||||
"legacy_retirement",
|
||||
"self_service_savings",
|
||||
"agent_efficiency",
|
||||
"agent_assist_sales",
|
||||
]
|
||||
expected = {
|
||||
"legacy_retirement": ({"1": 680_000, "2": 930_000, "3": 930_000}, 0.05),
|
||||
"self_service_savings": ({"1": 2_329_600, "2": 2_329_600, "3": 2_329_600}, 0.15),
|
||||
"agent_efficiency": ({"1": 2_912_000, "2": 2_912_000, "3": 2_912_000}, 0.10),
|
||||
"agent_assist_sales": ({"1": 600_000, "2": 600_000, "3": 600_000}, 0.05),
|
||||
}
|
||||
for key, (years, rf) in expected.items():
|
||||
row = _row(BENEFITS_VERBATIM, key)
|
||||
assert row["year_values"] == years
|
||||
assert row["risk_adjustment"] == rf
|
||||
assert row["table"] == "benefits"
|
||||
|
||||
|
||||
def test_cost_rows_verbatim():
|
||||
expected = {
|
||||
"cx_cloud_licenses": ({"1": 840_000, "2": 840_000, "3": 840_000}, 0.05, 0),
|
||||
"implementation": ({"1": 0, "2": 0, "3": 0}, 0.10, 1_190_000),
|
||||
"ongoing_management": ({"1": 202_800, "2": 202_800, "3": 202_800}, 0.10, 0),
|
||||
"genesys_ai_tokens": ({"1": 0, "2": 0, "3": 0}, 0.0, 0),
|
||||
}
|
||||
for key, (years, rf, initial) in expected.items():
|
||||
row = _row(COSTS_VERBATIM, key)
|
||||
assert row["year_values"] == years
|
||||
assert row["risk_adjustment"] == rf
|
||||
assert row["initial"] == initial
|
||||
assert row["table"] == "costs"
|
||||
|
||||
|
||||
def test_ai_token_line_is_anchored_at_zero():
|
||||
"""The published study models $0 AI consumption — the study's blind spot,
|
||||
preserved verbatim so the reproduction matches the published totals."""
|
||||
row = _row(COSTS_VERBATIM, "genesys_ai_tokens")
|
||||
assert all(v == 0 for v in row["year_values"].values())
|
||||
assert row["initial"] == 0 and row["risk_adjustment"] == 0.0
|
||||
assert "NOT in the published study" in row["notes"]
|
||||
|
||||
|
||||
def test_assumptions_and_published():
|
||||
assert ASSUMPTIONS["annual_revenue"] == 2_500_000_000
|
||||
assert ASSUMPTIONS["agents_fte"] == 600
|
||||
assert ASSUMPTIONS["concurrent_licenses"] == 400
|
||||
assert ASSUMPTIONS["weekly_interactions"] == 80_000
|
||||
assert ASSUMPTIONS["discount_rate"] == 0.10
|
||||
assert ASSUMPTIONS["analysis_years"] == 3
|
||||
|
||||
assert PUBLISHED["benefits_pv"] == 14_840_638
|
||||
assert PUBLISHED["costs_pv"] == 4_057_170
|
||||
assert PUBLISHED["npv"] == 10_783_468
|
||||
assert PUBLISHED["roi_pct"] == 266
|
||||
assert "payback" not in str(sorted(PUBLISHED)) # study doesn't headline one
|
||||
|
||||
# The composite drivers ARE the anchor assumptions (tokens at $0).
|
||||
assert COMPOSITE.agents_fte == ASSUMPTIONS["agents_fte"]
|
||||
assert COMPOSITE.weekly_interactions == ASSUMPTIONS["weekly_interactions"]
|
||||
assert COMPOSITE.annual_revenue == ASSUMPTIONS["annual_revenue"]
|
||||
assert COMPOSITE.ai_tokens_annual == 0.0
|
||||
assert COMPOSITE.discount_rate == ASSUMPTIONS["discount_rate"]
|
||||
|
||||
|
||||
def test_anchor_is_never_mutated():
|
||||
"""Exercise every engine code path, then prove the record unchanged."""
|
||||
ben_snap = deepcopy(BENEFITS_VERBATIM)
|
||||
cost_snap = deepcopy(COSTS_VERBATIM)
|
||||
|
||||
overlay_rows()
|
||||
overlay_rows(ClientDrivers(agents_fte=137, weekly_interactions=5_000,
|
||||
annual_revenue=9e9, ai_tokens_annual=450_000))
|
||||
for scenario in ("conservative", "moderate", "aggressive"):
|
||||
apply_scenario(BENEFITS_VERBATIM, scenario)
|
||||
apply_scenario(COSTS_VERBATIM, scenario)
|
||||
compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.08)
|
||||
|
||||
assert BENEFITS_VERBATIM == ben_snap
|
||||
assert COSTS_VERBATIM == cost_snap
|
||||
122
studies/202512_TEI_Genesys_CX_Cloud/tests/test_model.py
Normal file
122
studies/202512_TEI_Genesys_CX_Cloud/tests/test_model.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Engine pins — every number hand-checked before pinning.
|
||||
|
||||
RA_benefit = v×(1−rf), RA_cost = v×(1+rf), PV = Σ RA_n/(1.1)^n, initial
|
||||
undiscounted. The composite reproduction lands within $2 of the published
|
||||
Financial Summary (benefits PV $1.19 low, costs PV $0.40 high) — pinned
|
||||
both engine-exact (±$1) and against PUBLISHED (±$5). Forrester does not
|
||||
headline a payback for this study; the engine computes 3.3 months.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from teicalc import (
|
||||
BENEFITS_VERBATIM,
|
||||
COSTS_VERBATIM,
|
||||
PUBLISHED,
|
||||
X_LABELS,
|
||||
YEAR_INDEX,
|
||||
YEARS,
|
||||
by_calendar,
|
||||
compute_summary,
|
||||
discount_factor,
|
||||
money,
|
||||
npv,
|
||||
payback_label,
|
||||
payback_months,
|
||||
payback_years,
|
||||
roi_pct,
|
||||
)
|
||||
|
||||
# Hand-checked risk-adjusted PVs per row (see module docstring).
|
||||
ROW_PVS = {
|
||||
"legacy_retirement": 1_981_224.64,
|
||||
"self_service_savings": 4_924_364.84,
|
||||
"agent_efficiency": 6_517_541.70,
|
||||
"agent_assist_sales": 1_417_505.63,
|
||||
"cx_cloud_licenses": 2_193_403.46,
|
||||
"implementation": 1_309_000.00,
|
||||
"ongoing_management": 554_766.94,
|
||||
"genesys_ai_tokens": 0.00,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def composite():
|
||||
return compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
|
||||
|
||||
def test_calendar_mapping():
|
||||
assert YEARS == [2026, 2027, 2028]
|
||||
assert YEAR_INDEX == {2026: 1, 2027: 2, 2028: 3}
|
||||
assert X_LABELS == ["Initial", "2026", "2027", "2028"]
|
||||
assert by_calendar({"1": 10, "2": 20, "3": 30}) == {2026: 10, 2027: 20, 2028: 30}
|
||||
|
||||
|
||||
def test_primitives():
|
||||
assert discount_factor(0, 0.10) == 1.0
|
||||
assert discount_factor(1, 0.10) == pytest.approx(1 / 1.1)
|
||||
assert npv([110], 0.10) == pytest.approx(100)
|
||||
assert npv([110], 0.10, initial=-50) == pytest.approx(50)
|
||||
assert roi_pct(14_840_638, 4_057_170) == pytest.approx(265.79, abs=0.1)
|
||||
assert roi_pct(100, 0) == 0.0
|
||||
assert money(10_783_466) == "$10.8M"
|
||||
assert money(-250_000) == "-$250K"
|
||||
|
||||
|
||||
def test_payback_edges():
|
||||
assert payback_years(0, [100]) == 0.0
|
||||
assert payback_years(500, []) is None
|
||||
assert payback_years(500, [-100, 200]) is None
|
||||
assert payback_years(300, [-100, 400]) == pytest.approx(2.0)
|
||||
assert payback_months(100, [1_200]) == pytest.approx(1.0)
|
||||
assert payback_label(None) == "beyond 2028"
|
||||
assert payback_label(0.0) == "immediate"
|
||||
assert payback_label(3.3337) == "3.3 months (~Apr 2026)"
|
||||
assert payback_label(14.2) == "14.2 months (~Mar 2027)"
|
||||
|
||||
|
||||
def test_per_row_pvs(composite):
|
||||
rows = composite["rows"]["benefits"] + composite["rows"]["costs"]
|
||||
assert len(rows) == 8
|
||||
for row in rows:
|
||||
assert row["pv"] == pytest.approx(ROW_PVS[row["field_key"]], abs=1)
|
||||
|
||||
|
||||
def test_composite_totals_engine_exact(composite):
|
||||
assert composite["benefits_pv"] == pytest.approx(14_840_636.81, abs=1)
|
||||
assert composite["costs_pv"] == pytest.approx(4_057_170.40, abs=1)
|
||||
assert composite["npv"] == pytest.approx(10_783_466.42, abs=1)
|
||||
assert composite["roi_pct"] == pytest.approx(265.7879, abs=0.01)
|
||||
assert composite["payback_months"] == pytest.approx(3.3337, abs=0.001)
|
||||
assert composite["initial_costs"] == pytest.approx(1_309_000, abs=0.01)
|
||||
|
||||
|
||||
def test_composite_reproduces_published(composite):
|
||||
assert composite["benefits_pv"] == pytest.approx(PUBLISHED["benefits_pv"], abs=5)
|
||||
assert composite["costs_pv"] == pytest.approx(PUBLISHED["costs_pv"], abs=5)
|
||||
assert composite["npv"] == pytest.approx(PUBLISHED["npv"], abs=5)
|
||||
assert round(composite["roi_pct"]) == PUBLISHED["roi_pct"]
|
||||
assert composite["payback_label"] == "3.3 months (~Apr 2026)"
|
||||
|
||||
|
||||
def test_yearly_schedules(composite):
|
||||
assert composite["benefits_by_year"][2026] == pytest.approx(5_816_960.00, abs=0.01)
|
||||
assert composite["benefits_by_year"][2027] == pytest.approx(6_054_460.00, abs=0.01)
|
||||
assert composite["benefits_by_year"][2028] == pytest.approx(6_054_460.00, abs=0.01)
|
||||
for y in YEARS:
|
||||
assert composite["costs_by_year"][y] == pytest.approx(1_105_080.00, abs=0.01)
|
||||
assert composite["cumulative_net_by_year"][2026] == pytest.approx(3_402_880.00, abs=0.01)
|
||||
assert composite["cumulative_net_by_year"][2028] == pytest.approx(13_301_640.00, abs=0.01)
|
||||
|
||||
|
||||
def test_cross_foots(composite):
|
||||
assert composite["npv"] == pytest.approx(
|
||||
composite["benefits_pv"] - composite["costs_pv"], abs=0.01)
|
||||
for y in YEARS:
|
||||
assert composite["net_by_year"][y] == pytest.approx(
|
||||
composite["benefits_by_year"][y] - composite["costs_by_year"][y], abs=0.01)
|
||||
assert composite["cumulative_net_by_year"][2028] == pytest.approx(
|
||||
sum(composite["net_by_year"].values()) - composite["initial_costs"], abs=0.01)
|
||||
for table, total in (("benefits", "benefits_pv"), ("costs", "costs_pv")):
|
||||
assert sum(r["pv"] for r in composite["rows"][table]) == pytest.approx(
|
||||
composite[total], abs=0.01)
|
||||
95
studies/202512_TEI_Genesys_CX_Cloud/tests/test_overlay.py
Normal file
95
studies/202512_TEI_Genesys_CX_Cloud/tests/test_overlay.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Client-overlay pins — identity at the composite, linear per-driver
|
||||
scaling, the direct AI-token input, and copy semantics."""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from teicalc import (
|
||||
BENEFIT_DRIVERS,
|
||||
BENEFITS_VERBATIM,
|
||||
COMPOSITE,
|
||||
COST_DRIVERS,
|
||||
COSTS_VERBATIM,
|
||||
ClientDrivers,
|
||||
compute_summary,
|
||||
overlay_rows,
|
||||
scale_factor,
|
||||
)
|
||||
|
||||
|
||||
def _row(rows, key):
|
||||
return next(r for r in rows if r["field_key"] == key)
|
||||
|
||||
|
||||
def test_identity_at_composite():
|
||||
"""overlay_rows(COMPOSITE) reproduces the verbatim study to the cent."""
|
||||
ob, oc = overlay_rows(COMPOSITE)
|
||||
got = compute_summary(ob, oc, 0.10)
|
||||
want = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
assert got["benefits_pv"] == pytest.approx(want["benefits_pv"], abs=0.01)
|
||||
assert got["costs_pv"] == pytest.approx(want["costs_pv"], abs=0.01)
|
||||
assert got["npv"] == pytest.approx(want["npv"], abs=0.01)
|
||||
|
||||
|
||||
def test_driver_map_covers_every_row():
|
||||
assert set(BENEFIT_DRIVERS) == {r["field_key"] for r in BENEFITS_VERBATIM}
|
||||
assert set(COST_DRIVERS) == {r["field_key"] for r in COSTS_VERBATIM}
|
||||
|
||||
|
||||
def test_scale_factor():
|
||||
d = ClientDrivers(agents_fte=300, weekly_interactions=160_000,
|
||||
annual_revenue=5_000_000_000)
|
||||
assert scale_factor("agents", d) == pytest.approx(0.5)
|
||||
assert scale_factor("interactions", d) == pytest.approx(2.0)
|
||||
assert scale_factor("revenue", d) == pytest.approx(2.0)
|
||||
assert scale_factor("fixed", d) == 1.0
|
||||
with pytest.raises(KeyError):
|
||||
scale_factor("contacts", d)
|
||||
|
||||
|
||||
def test_half_agents_halves_agent_rows_only():
|
||||
ob, oc = overlay_rows(ClientDrivers(agents_fte=300))
|
||||
assert _row(ob, "legacy_retirement")["year_values"]["1"] == pytest.approx(340_000)
|
||||
assert _row(oc, "cx_cloud_licenses")["year_values"]["1"] == pytest.approx(420_000)
|
||||
# Interaction-, revenue-driven, and fixed rows unmoved.
|
||||
assert _row(ob, "self_service_savings")["year_values"]["1"] == pytest.approx(2_329_600)
|
||||
assert _row(ob, "agent_assist_sales")["year_values"]["1"] == pytest.approx(600_000)
|
||||
assert _row(oc, "implementation")["initial"] == 1_190_000
|
||||
|
||||
|
||||
def test_double_interactions_doubles_volume_rows_only():
|
||||
ob, oc = overlay_rows(ClientDrivers(weekly_interactions=160_000))
|
||||
assert _row(ob, "self_service_savings")["year_values"]["1"] == pytest.approx(4_659_200)
|
||||
assert _row(ob, "agent_efficiency")["year_values"]["1"] == pytest.approx(5_824_000)
|
||||
assert _row(ob, "legacy_retirement")["year_values"]["1"] == pytest.approx(680_000)
|
||||
assert _row(oc, "cx_cloud_licenses")["year_values"]["1"] == pytest.approx(840_000)
|
||||
|
||||
|
||||
def test_double_revenue_doubles_agent_assist_only():
|
||||
ob, _ = overlay_rows(ClientDrivers(annual_revenue=5_000_000_000))
|
||||
assert _row(ob, "agent_assist_sales")["year_values"]["1"] == pytest.approx(1_200_000)
|
||||
assert _row(ob, "self_service_savings")["year_values"]["1"] == pytest.approx(2_329_600)
|
||||
|
||||
|
||||
def test_ai_tokens_direct_input():
|
||||
"""The token line takes the negotiated annual figure directly (rf 0.0),
|
||||
adding annual × Σ1/1.1ⁿ = 250,000 × 2.48685… ≈ $621,713 to costs PV."""
|
||||
_, oc = overlay_rows(ClientDrivers(ai_tokens_annual=250_000))
|
||||
tokens = _row(oc, "genesys_ai_tokens")
|
||||
assert tokens["year_values"] == {"1": 250_000.0, "2": 250_000.0, "3": 250_000.0}
|
||||
base = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
ob, oc = overlay_rows(ClientDrivers(ai_tokens_annual=250_000))
|
||||
got = compute_summary(ob, oc, 0.10)
|
||||
assert got["costs_pv"] - base["costs_pv"] == pytest.approx(621_713.00, abs=1)
|
||||
assert got["benefits_pv"] == pytest.approx(base["benefits_pv"], abs=0.01)
|
||||
|
||||
|
||||
def test_drivers_frozen_and_rows_are_copies():
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
COMPOSITE.agents_fte = 1 # type: ignore[misc]
|
||||
ob, oc = overlay_rows(COMPOSITE)
|
||||
ob[0]["year_values"]["1"] = -1
|
||||
oc[0]["year_values"]["1"] = -1
|
||||
assert BENEFITS_VERBATIM[0]["year_values"]["1"] == 680_000
|
||||
assert COSTS_VERBATIM[0]["year_values"]["1"] == 840_000
|
||||
74
studies/202512_TEI_Genesys_CX_Cloud/tests/test_scenarios.py
Normal file
74
studies/202512_TEI_Genesys_CX_Cloud/tests/test_scenarios.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Scenario pins — hand-checked composite results per scenario, clamp
|
||||
behaviour, and copy semantics."""
|
||||
|
||||
import pytest
|
||||
|
||||
from teicalc import (
|
||||
BENEFITS_VERBATIM,
|
||||
COSTS_VERBATIM,
|
||||
SCENARIOS,
|
||||
apply_scenario,
|
||||
compute_summary,
|
||||
)
|
||||
|
||||
|
||||
def _summary(scenario):
|
||||
return compute_summary(
|
||||
apply_scenario(BENEFITS_VERBATIM, scenario),
|
||||
apply_scenario(COSTS_VERBATIM, scenario),
|
||||
0.10,
|
||||
)
|
||||
|
||||
|
||||
def test_scenario_definitions():
|
||||
assert SCENARIOS == {
|
||||
"conservative": {"adoption": 0.80, "risk_delta": 0.10},
|
||||
"moderate": {"adoption": 1.00, "risk_delta": 0.00},
|
||||
"aggressive": {"adoption": 1.15, "risk_delta": -0.05},
|
||||
}
|
||||
|
||||
|
||||
def test_moderate_is_identity():
|
||||
got = _summary("moderate")
|
||||
want = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
assert got["benefits_pv"] == pytest.approx(want["benefits_pv"], abs=0.01)
|
||||
assert got["costs_pv"] == pytest.approx(want["costs_pv"], abs=0.01)
|
||||
|
||||
|
||||
def test_conservative_pins():
|
||||
s = _summary("conservative")
|
||||
assert s["benefits_pv"] == pytest.approx(10_543_493.91, abs=1)
|
||||
assert s["costs_pv"] == pytest.approx(3_026_631.40, abs=1)
|
||||
assert s["npv"] == pytest.approx(7_516_862.51, abs=1)
|
||||
assert s["roi_pct"] == pytest.approx(248.36, abs=0.01)
|
||||
assert s["payback_months"] == pytest.approx(3.464, abs=0.001)
|
||||
|
||||
|
||||
def test_aggressive_pins():
|
||||
s = _summary("aggressive")
|
||||
assert s["benefits_pv"] == pytest.approx(18_021_962.25, abs=1)
|
||||
assert s["costs_pv"] == pytest.approx(4_883_285.09, abs=1)
|
||||
assert s["npv"] == pytest.approx(13_138_677.16, abs=1)
|
||||
assert s["roi_pct"] == pytest.approx(269.05, abs=0.01)
|
||||
assert s["payback_months"] == pytest.approx(3.294, abs=0.001)
|
||||
|
||||
|
||||
def test_risk_delta_clamps_at_zero():
|
||||
"""Conservative subtracts 0.10 from cost risk; every cost rf clamps to 0
|
||||
(licenses 0.05, implementation 0.10, ongoing 0.10, tokens 0.0)."""
|
||||
rows = apply_scenario(COSTS_VERBATIM, "conservative")
|
||||
assert all(r["risk_adjustment"] == 0.0 for r in rows)
|
||||
impl = next(r for r in rows if r["field_key"] == "implementation")
|
||||
assert impl["initial"] == pytest.approx(1_190_000 * 0.80) # adoption scales initial
|
||||
|
||||
|
||||
def test_unknown_scenario_raises():
|
||||
with pytest.raises(KeyError):
|
||||
apply_scenario(BENEFITS_VERBATIM, "wildly_optimistic")
|
||||
|
||||
|
||||
def test_inputs_not_mutated():
|
||||
apply_scenario(BENEFITS_VERBATIM, "aggressive")
|
||||
apply_scenario(COSTS_VERBATIM, "conservative")
|
||||
assert BENEFITS_VERBATIM[0]["year_values"]["1"] == 680_000
|
||||
assert COSTS_VERBATIM[1]["initial"] == 1_190_000
|
||||
15
studies/202512_TEI_Genesys_CX_Cloud/tests/test_staging.py
Normal file
15
studies/202512_TEI_Genesys_CX_Cloud/tests/test_staging.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Stage/backstage detection — Mercury kernels carry MERCURY_CONFIG_DIR."""
|
||||
|
||||
from teicalc import staging
|
||||
|
||||
|
||||
def test_backstage_prints_only_off_stage(monkeypatch, capsys):
|
||||
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
|
||||
assert not staging.on_stage()
|
||||
staging.backstage("visible")
|
||||
assert capsys.readouterr().out == "visible\n"
|
||||
|
||||
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
|
||||
assert staging.on_stage()
|
||||
staging.backstage("hidden")
|
||||
assert capsys.readouterr().out == ""
|
||||
Reference in New Issue
Block a user