Migrate Amazon Connect TEI study to the Mercury Notebook Pattern
studies/202602_AmazonConnect -> studies/202602_TEI_Amazon_Connect, rebuilt as pattern Variant 4 (TEI composite reproduction): - teicalc/ self-contained engine (stdlib-only): Forrester's tables as the never-edited verbatim anchor, NPV/ROI/payback + risk adjustment transplanted from core/calculations, ClientDrivers overlay (contacts/ agents/fixed driver map, growth re-base, identity at composite scale), scenario stress with core-identical semantics - one deliverable notebook (business_case.ipynb): widget-pair sidebar drivers, published-vs-overlay KPI columns, cash-flow/waterfall/scenario charts, verification gate, backstage JSON data appendix - gate + tests reproduce the published totals within PDF rounding: NPV $78.7M / ROI 342% / payback <6 months (engine $78,713,492 / 342.48% / 0.7 months); 27 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) deleted; git history preserves it; root test fixture repointed to teicalc.anchor - docs: study README rewritten; root README points new studies at template/MercuryNotebook; pattern doc stale ctm-token-calculator paths now cite studies/202607_CTM_GenesysCX; Variant 4 cites this study as its realized reference Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
78
README.md
78
README.md
@@ -12,7 +12,7 @@ Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Palladium │
|
||||
│ │
|
||||
│ studies/202602_AmazonConnect/ ← one folder per TEI study │
|
||||
│ studies/202512_GenesysCX/ ← legacy study (this path) │
|
||||
│ studies/YYYYMM_<Vendor>/ │
|
||||
│ ├─ notebooks/ ─┐ │
|
||||
│ ├─ seed_data.py │ │
|
||||
@@ -78,7 +78,7 @@ From any notebook, setup is one import:
|
||||
```python
|
||||
from core.bootstrap import init
|
||||
|
||||
pal = init(study="202602_AmazonConnect") # loads .env, connects, imports study
|
||||
pal = init(study="202512_GenesysCX") # loads .env, connects, imports study
|
||||
pal.client.list_reports()
|
||||
pal.seed_data.BENEFITS
|
||||
```
|
||||
@@ -113,23 +113,26 @@ python -m palladium test
|
||||
|
||||
### Run a study end-to-end
|
||||
|
||||
Each study lives in `studies/<slug>/`. The reference study is the
|
||||
February 2026 Forrester *Total Economic Impact™ Of Amazon Connect*:
|
||||
Each new study is self-contained under the
|
||||
[Mercury Notebook Deliverable Pattern](docs/Mercury_Notebook_Pattern_V1-00.md).
|
||||
The reference TEI study is the February 2026 Forrester *Total Economic
|
||||
Impact™ Of Amazon Connect* (pattern Variant 4 — composite reproduction):
|
||||
|
||||
```bash
|
||||
make lab # then browse to studies/202602_AmazonConnect/notebooks/
|
||||
cd studies/202602_TEI_Amazon_Connect
|
||||
python -m venv .venv && source .venv/bin/activate && pip install -e ".[dev]"
|
||||
mercury --working-dir notebooks/ # serve the deliverable (the stage)
|
||||
python scripts/export_report.py # export .html/.md report sources
|
||||
```
|
||||
|
||||
| Notebook | Purpose |
|
||||
|----------|---------|
|
||||
| `00_provision.ipynb` | **Run first** — creates the report template + fields, lets you select the CRM client and the Proposal/Engagement to attach to (pulling the client's profile to avoid re-entry), creates the tool, seeds the published values, calculates, and verifies the totals |
|
||||
| `01_benefits.ipynb` | Quantify and risk-adjust benefit categories |
|
||||
| `02_costs.ipynb` | Document implementation and ongoing costs |
|
||||
| `03_business_case.ipynb` | Financial summary, scenario analysis, visualizations |
|
||||
| `04_export.ipynb` | Generate report-ready JSON for the html2docx pipeline |
|
||||
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 Amazon Connect notebooks reproduce the published study totals within
|
||||
rounding: **NPV $78.7M • ROI 342% • Payback <6 months**.
|
||||
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)
|
||||
|
||||
@@ -176,20 +179,17 @@ the published Forrester totals.
|
||||
|
||||
## Adding a new study
|
||||
|
||||
Copy the template, not an existing study:
|
||||
|
||||
```bash
|
||||
cp -r studies/202602_AmazonConnect studies/202612_GenesysCloud
|
||||
cd studies/202612_GenesysCloud
|
||||
cp -r template/MercuryNotebook studies/YYYYMM_TEI_Vendor_Product
|
||||
```
|
||||
|
||||
1. **`README.md`** — update the title, source citation, key numbers.
|
||||
2. **`seed_data.py`** — replace `BENEFITS` and `COSTS` with the new study's rows.
|
||||
3. **`config.py`** — set `STUDY_SLUG`, leave `TOOL_PUBLIC_ID` blank until provisioned.
|
||||
4. **`docs/`** — drop the source PDF here.
|
||||
5. Open the notebooks; the imports (`core.calculations`, `core.notebook_helpers`,
|
||||
`core.tei_client`) are study-agnostic. Update the markdown narrative.
|
||||
|
||||
The shared `core/` package and the `app/` Streamlit UI need no changes —
|
||||
they introspect the TEI Report template via the API.
|
||||
Then follow `template/MercuryNotebook/README.md`: rename `studylib/` to
|
||||
your study package (underscores only — dashes break Python imports),
|
||||
replace the toy model, re-pin the tests, rework the notebook.
|
||||
`studies/202602_TEI_Amazon_Connect/` is the worked TEI example;
|
||||
`studies/202607_CTM_GenesysCX/` is the full multi-notebook reference.
|
||||
|
||||
---
|
||||
|
||||
@@ -269,24 +269,22 @@ palladium/
|
||||
│ ├── main.py # entry point
|
||||
│ ├── views/ # benefits, costs, summary, versions (NOT `pages/` — avoids Streamlit auto-multipage)
|
||||
│ └── components/ # tables, charts
|
||||
├── studies/ # One folder per TEI engagement
|
||||
│ ├── 202512_GenesysCX/ # CX Cloud (Genesys + Salesforce) TEI
|
||||
├── 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
|
||||
│ └── 202602_AmazonConnect/
|
||||
│ ├── README.md
|
||||
│ ├── config.py # TOOL_PUBLIC_ID, REPORT_PUBLIC_ID
|
||||
│ ├── seed_data.py # 5 benefits + 3 costs from the PDF
|
||||
│ ├── notebooks/
|
||||
│ │ ├── 00_provision.ipynb # creates template+tool in Athena, seeds & verifies
|
||||
│ │ ├── 01_benefits.ipynb
|
||||
│ │ ├── 02_costs.ipynb
|
||||
│ │ ├── 03_business_case.ipynb
|
||||
│ │ └── 04_export.ipynb
|
||||
│ ├── exports/ # generated; .gitignored
|
||||
│ └── docs/
|
||||
│ └── 202602_TEI Report Amazon Connect.pdf
|
||||
│ ├── 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)
|
||||
│ │ ├── notebooks/business_case.ipynb
|
||||
│ │ ├── tests/ · scripts/ · config.toml · pyproject.toml
|
||||
│ │ ├── exports/ # generated; .gitignored
|
||||
│ │ └── docs/
|
||||
│ │ └── 202602_TEI Report Amazon Connect.pdf
|
||||
│ └── 202607_CTM_GenesysCX/ # CTM × Genesys study — pattern reference impl
|
||||
├── tests/ # 50 tests for core/
|
||||
│ ├── test_client.py
|
||||
│ ├── test_calculations.py
|
||||
|
||||
@@ -15,7 +15,7 @@ agent building or modifying a study. Rules are imperative (MUST/SHOULD/NEVER), e
|
||||
with a one-line *why*. Long code lives in the runnable template
|
||||
[`template/MercuryNotebook/`](../template/MercuryNotebook/) — copy it to start a study;
|
||||
snippets here are excerpts from it. The full-scale reference implementation is the CTM
|
||||
Genesys study, [`studies/202512_GenesysCX/ctm-token-calculator/`](../studies/202512_GenesysCX/ctm-token-calculator/).
|
||||
Genesys study, [`studies/202607_CTM_GenesysCX/`](../studies/202607_CTM_GenesysCX/).
|
||||
|
||||
---
|
||||
|
||||
@@ -58,13 +58,11 @@ palladium/
|
||||
│ └── MercuryNotebook/ # copy-me starting point (runnable)
|
||||
└── studies/
|
||||
├── YYYYMM_TEI_Vendor_Product/ # vendor TEI study, e.g. 202602_TEI_Amazon_Connect
|
||||
└── YYYYMM_Client_EngagementName/ # client study, e.g. 202512_CTM_GenesysCX
|
||||
└── YYYYMM_Client_EngagementName/ # client study, e.g. 202607_CTM_GenesysCX
|
||||
```
|
||||
|
||||
- Names MUST use **underscores, never dashes** — dashed directories can't be Python
|
||||
packages, and everything in a study is importable code.
|
||||
*(The CTM study's inner `ctm-token-calculator/` predates this rule; it gets renamed
|
||||
when the studies migrate.)*
|
||||
- Every study is self-contained with this layout (from the template):
|
||||
|
||||
```
|
||||
@@ -238,7 +236,7 @@ Each section heading carries `<a id="section-N"></a>`; a sidebar table of conten
|
||||
**onclick-JS** navigation (fragment `href`s don't scroll in Mercury's SPA, and
|
||||
python-markdown escapes any raw `<` inside handler attributes — keep handlers
|
||||
comparison-free). Recipe: the ToC cell in
|
||||
[`studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb`](../studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb).
|
||||
[`studies/202607_CTM_GenesysCX/notebooks/ctm_business_case_corrected.ipynb`](../studies/202607_CTM_GenesysCX/notebooks/ctm_business_case_corrected.ipynb).
|
||||
|
||||
---
|
||||
|
||||
@@ -263,7 +261,7 @@ def anchor(key):
|
||||
overlays are auditable; corrections made by editing the source are arguments. Show a
|
||||
**vendor/deck-frame KPI column beside the contracted column** so the walk from the
|
||||
pitch to reality stays explicit. Reference: `TCO_VERBATIM`/`TCO_CONTRACTED`/`tco()` in
|
||||
[`studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py`](../studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py).
|
||||
[`studies/202607_CTM_GenesysCX/tokencalc/appendix4.py`](../studies/202607_CTM_GenesysCX/tokencalc/appendix4.py).
|
||||
|
||||
### Baseline-relative case frame
|
||||
|
||||
@@ -316,28 +314,31 @@ Keep the vendor's claimed benefits **verbatim**, add the costs the pitch omitted
|
||||
(consumption meters, implementation labour, double-billing), and bill contract
|
||||
mechanics as signed (ramp, milestones, managed services). The headline is the walk:
|
||||
*as-pitched → corrected*. Reference:
|
||||
[`notebooks/ctm_business_case_corrected.ipynb`](../studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb).
|
||||
[`notebooks/ctm_business_case_corrected.ipynb`](../studies/202607_CTM_GenesysCX/notebooks/ctm_business_case_corrected.ipynb).
|
||||
|
||||
### Variant 2 — Scenario notebook on a thin module
|
||||
|
||||
A second question over the same engine (e.g. "migration + WFM only, no AI") gets a
|
||||
**thin scenario module** that scopes and extrapolates but duplicates nothing, plus its
|
||||
own notebook and test pins. Reference: `tokencalc/migration_wfm.py` +
|
||||
[`notebooks/ctm_migration_wfm.ipynb`](../studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_migration_wfm.ipynb).
|
||||
[`notebooks/ctm_migration_wfm.ipynb`](../studies/202607_CTM_GenesysCX/notebooks/ctm_migration_wfm.ipynb).
|
||||
|
||||
### Variant 3 — Exploratory calculator
|
||||
|
||||
Early-phase what-if surface: scenario selectors, tornado/break-even sweeps, no
|
||||
contract anchoring yet. Still engine-backed and gate-checked; it graduates into
|
||||
Variant 1 as facts arrive. Reference:
|
||||
[`notebooks/ctm_token_calculator.ipynb`](../studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_token_calculator.ipynb).
|
||||
[`notebooks/ctm_token_calculator.ipynb`](../studies/202607_CTM_GenesysCX/notebooks/ctm_token_calculator.ipynb).
|
||||
|
||||
### Variant 4 — TEI composite reproduction
|
||||
|
||||
Reproduce a published TEI study's composite organization as the verbatim anchor
|
||||
(`ANCHOR_VERBATIM` = Forrester's tables), verify the reproduction against the published
|
||||
ROI/NPV/payback in the gate, then personalize with client inputs as the overlay. This
|
||||
is the target shape for `studies/202602_AmazonConnect/` when it migrates off Streamlit.
|
||||
is realized by [`studies/202602_TEI_Amazon_Connect/`](../studies/202602_TEI_Amazon_Connect/)
|
||||
(package `teicalc`): Forrester's Amazon Connect composite as the anchor, the gate pinning
|
||||
the published $78.7M NPV / 342% ROI / <6-month payback, and a client-driver overlay
|
||||
(agents / contacts / growth) that is the identity at composite scale.
|
||||
|
||||
---
|
||||
|
||||
|
||||
BIN
studies/202602_AmazonConnect/.DS_Store
vendored
BIN
studies/202602_AmazonConnect/.DS_Store
vendored
Binary file not shown.
@@ -1,71 +0,0 @@
|
||||
# 202602 — Amazon Connect TEI
|
||||
|
||||
Self-contained TEI study folder. All data, notebooks, and exports for the
|
||||
Forrester *Total Economic Impact™ Of Amazon Connect* (February 2026,
|
||||
commissioned by AWS) live here.
|
||||
|
||||
## Source
|
||||
|
||||
The full Forrester study is at [`docs/202602_TEI Report Amazon Connect.pdf`](docs/202602_TEI%20Report%20Amazon%20Connect.pdf).
|
||||
|
||||
Key composite numbers reproduced in `seed_data.py`:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| ROI | **342%** |
|
||||
| NPV | **$78.7M** |
|
||||
| Benefits PV | $101.7M |
|
||||
| Costs PV | $23.0M |
|
||||
| Payback | <6 months |
|
||||
| Discount rate | 10% |
|
||||
| Analysis period | 3 years |
|
||||
|
||||
## Composite organization
|
||||
|
||||
* Global B2C, ~$10B revenue (Y1), 30% YoY growth
|
||||
* 2,000 contact-center agents, 200 supervisors
|
||||
* 20M annual contacts (75% calls, 25% chat)
|
||||
* 10-min average handle time
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
202602_AmazonConnect/
|
||||
├── README.md ← this file
|
||||
├── config.py ← TOOL_PUBLIC_ID, REPORT_PUBLIC_ID, study slug
|
||||
├── seed_data.py ← BENEFITS, COSTS, ASSUMPTIONS as Python dicts
|
||||
├── notebooks/
|
||||
│ ├── 01_benefits.ipynb ← quantify the 5 benefits, push to Athena
|
||||
│ ├── 02_costs.ipynb ← quantify the 3 costs
|
||||
│ ├── 03_business_case.ipynb ← /calculate, charts, scenarios
|
||||
│ └── 04_export.ipynb ← /export → exports/export.json
|
||||
├── exports/ ← generated; .gitignored
|
||||
└── docs/
|
||||
└── 202602_TEI Report Amazon Connect.pdf
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Set up credentials** in the project root `.env` (see `.env.example`).
|
||||
2. **Create / link the TEI tool** in Athena, then put its `public_id` in
|
||||
[`config.py`](config.py).
|
||||
3. **Open `notebooks/01_benefits.ipynb`** and run all — pushes the 5
|
||||
benefit rows from `seed_data.py` into Athena.
|
||||
4. **`02_costs.ipynb`** — pushes the 3 cost rows.
|
||||
5. **`03_business_case.ipynb`** — calls `/calculate`, renders the cash
|
||||
flow chart, runs scenario analysis. Should reproduce the PDF's
|
||||
$78.7M NPV / 342% ROI.
|
||||
6. **`04_export.ipynb`** — writes `exports/export.json` for the report
|
||||
pipeline.
|
||||
|
||||
## Adding a new study
|
||||
|
||||
Copy this folder, rename to `YYYYMM_<Vendor><Solution>`, and:
|
||||
|
||||
1. Replace `seed_data.py` with your benefits/costs.
|
||||
2. Update `config.py` with the new tool/report public IDs.
|
||||
3. Tweak the notebooks' narrative; the helper imports are the same.
|
||||
|
||||
The only thing that changes between studies is the **data** and the
|
||||
**narrative prose** in the notebooks. All math, charts, and API calls
|
||||
come from `core/`.
|
||||
@@ -1,44 +0,0 @@
|
||||
"""
|
||||
Study configuration for the Amazon Connect TEI (February 2026).
|
||||
|
||||
Set ``TOOL_PUBLIC_ID`` to the public_id of the live TEI tool instance in
|
||||
Athena once it has been created. ``REPORT_PUBLIC_ID`` is the template
|
||||
this tool was created from (Athena admin sets up Report templates).
|
||||
|
||||
Until both are filled in, the notebooks fall back to local-only mode:
|
||||
they compute summaries from ``seed_data.py`` using ``core.calculations``
|
||||
and skip the network round-trip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
#: Human-friendly study identifier — used in export metadata + filenames.
|
||||
STUDY_SLUG = "202602_AmazonConnect"
|
||||
|
||||
#: TEI Report template public_id (12-char short UUID). Provisioned in
|
||||
#: Athena admin → TEI → Reports.
|
||||
REPORT_PUBLIC_ID: str = os.getenv("PALLADIUM_REPORT_PUBLIC_ID", "")
|
||||
|
||||
#: TEI Tool instance public_id. Created via the API
|
||||
#: (``client.create_tool``) or the Streamlit app sidebar.
|
||||
TOOL_PUBLIC_ID: str = os.getenv("PALLADIUM_TOOL_PUBLIC_ID", "")
|
||||
|
||||
#: Default discount rate used for local validation of the study numbers.
|
||||
DISCOUNT_RATE = 0.10
|
||||
|
||||
#: Analysis horizon (years).
|
||||
ANALYSIS_YEARS = 3
|
||||
|
||||
def _int_env(name: str) -> int | None:
|
||||
raw = os.getenv(name, "").strip()
|
||||
return int(raw) if raw else None
|
||||
|
||||
|
||||
#: Athena Proposal PK this tool is linked to (a TEI tool must attach to a
|
||||
#: Proposal OR an Engagement — set exactly one).
|
||||
PROPOSAL_ID: int | None = _int_env("PALLADIUM_PROPOSAL_ID")
|
||||
|
||||
#: Athena Engagement PK (alternative attachment point).
|
||||
ENGAGEMENT_ID: int | None = _int_env("PALLADIUM_ENGAGEMENT_ID")
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,195 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "15a4163e",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 04 — 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": "18f02ef8",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import sys\n",
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"ROOT = Path.cwd().resolve()\n",
|
||||
"while ROOT != ROOT.parent and not (ROOT / 'core').is_dir():\n",
|
||||
" ROOT = ROOT.parent\n",
|
||||
"if str(ROOT) not in sys.path:\n",
|
||||
" sys.path.insert(0, str(ROOT))\n",
|
||||
"STUDY = ROOT / 'studies' / '202602_AmazonConnect'\n",
|
||||
"if str(STUDY) not in sys.path:\n",
|
||||
" sys.path.insert(0, str(STUDY))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "7d91c01d",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import json\n",
|
||||
"from datetime import datetime, timezone\n",
|
||||
"\n",
|
||||
"import config\n",
|
||||
"import seed_data\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": "cff0b35b",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Build the envelope\n",
|
||||
"\n",
|
||||
"Two paths:\n",
|
||||
"\n",
|
||||
"* **Live** — `core.export.build_report_data(client, public_id)` pulls\n",
|
||||
" authoritative values + summary from Athena and stamps it.\n",
|
||||
"* **Local** — 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": "19416ff3",
|
||||
"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_data.BENEFITS, seed_data.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_data.BENEFITS, name, table='benefits')\n",
|
||||
" sc = apply_scenario(seed_data.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': 'Amazon Connect TEI (local seed)',\n",
|
||||
" 'report_name': 'Total Economic Impact™ Of Amazon Connect',\n",
|
||||
" 'report_vendor': 'AWS',\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™ Of Amazon Connect',\n",
|
||||
" 'vendor': 'AWS',\n",
|
||||
" 'version': '1.0',\n",
|
||||
" 'discount_rate': config.DISCOUNT_RATE,\n",
|
||||
" 'analysis_period_years': config.ANALYSIS_YEARS,\n",
|
||||
" },\n",
|
||||
" 'values': {'benefits': seed_data.BENEFITS, 'costs': seed_data.COSTS},\n",
|
||||
" 'summary': summary,\n",
|
||||
" 'scenarios': scenarios,\n",
|
||||
" 'assumptions': seed_data.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": "98e94d07",
|
||||
"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": "d09cad64",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Envelope shape\n",
|
||||
"\n",
|
||||
"Top-level keys consumed by the report pipeline:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "841f12a1",
|
||||
"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": "17d6d0ce",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Done. Hand off `exports/export.json` to **Peitho** / **html2docx** to produce the final Word report."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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
|
||||
}
|
||||
97
studies/202602_TEI_Amazon_Connect/README.md
Normal file
97
studies/202602_TEI_Amazon_Connect/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 202602 — Amazon Connect TEI
|
||||
|
||||
Self-contained reproduction of the Forrester *Total Economic Impact™ Of
|
||||
Amazon Connect* study (February 2026, commissioned by AWS), 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/202602_TEI Report Amazon Connect.pdf`](docs/202602_TEI%20Report%20Amazon%20Connect.pdf).
|
||||
|
||||
Published composite totals (3-yr risk-adjusted PV @ 10%), reproduced by
|
||||
`teicalc` to within the PDF's own table rounding (benefits PV lands $223
|
||||
low; costs PV $0.22 low):
|
||||
|
||||
| Metric | Published | Engine |
|
||||
|---|---|---|
|
||||
| Benefits PV | **$101,696,791** | $101,696,568 |
|
||||
| Costs PV | **$22,983,076** | $22,983,076 |
|
||||
| NPV | **$78,713,715** | $78,713,492 |
|
||||
| ROI | **342%** | 342.48% |
|
||||
| Payback | **<6 months** | 0.7 months |
|
||||
|
||||
## Composite organization (the verbatim anchor 🟢)
|
||||
|
||||
* Global B2C, ~$10B revenue (Y1), 30% YoY growth
|
||||
* 2,000 contact-center agents, 200 supervisors
|
||||
* 20M annual contacts (75% calls, 25% chat)
|
||||
* 10-min average handle time
|
||||
|
||||
## Client overlay (🟡)
|
||||
|
||||
A first-order linear rescale — "the composite at your size", not "your
|
||||
TEI". Each published row scales with the driver that dominates its PDF
|
||||
derivation:
|
||||
|
||||
| Row | Driver | Confidence |
|
||||
|---|---|---|
|
||||
| AI contact resolution · content/sentiment | contacts | 🟡 |
|
||||
| Forecasting/supervision · legacy savings | agents | 🟡 |
|
||||
| Data-driven profit lift | contacts | 🔴 proxy (revenue-driven in the PDF) |
|
||||
| Amazon Connect usage | contacts | 🟡 |
|
||||
| Implementation · ongoing management | fixed | 🟡 project-based |
|
||||
|
||||
The client growth rate re-bases the composite's Y1→Y3 trajectory (which
|
||||
embeds 30% YoY). At composite scale the overlay is the identity — the
|
||||
gate asserts it.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
202602_TEI_Amazon_Connect/
|
||||
├── 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 + overlay_rows
|
||||
│ ├── 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
|
||||
└── 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` — it replaces the retired `exports/export.json`
|
||||
pipeline and is 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 → export).
|
||||
That workflow was retired when the study migrated to the pattern
|
||||
(git history preserves it); the engine reproduces the same published
|
||||
totals locally, pinned in `tests/`.
|
||||
81
studies/202602_TEI_Amazon_Connect/config.toml
Normal file
81
studies/202602_TEI_Amazon_Connect/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 = "Amazon Connect TEI — Business Case"
|
||||
favicon_emoji = "📊"
|
||||
footer = "Amazon Connect TEI study (Forrester, Feb 2026)"
|
||||
notebooks_button_label = "Analyses"
|
||||
|
||||
[welcome]
|
||||
header = "Amazon Connect TEI"
|
||||
message = """
|
||||
Interactive reproduction of Forrester's *Total Economic Impact™ Of Amazon
|
||||
Connect* composite ($78.7M NPV · 342% ROI). The published study is the
|
||||
verbatim anchor; tune the 🟡 client drivers live to rescale the composite
|
||||
to your organization, 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)"
|
||||
6946
studies/202602_TEI_Amazon_Connect/notebooks/business_case.ipynb
Normal file
6946
studies/202602_TEI_Amazon_Connect/notebooks/business_case.ipynb
Normal file
File diff suppressed because one or more lines are too long
36
studies/202602_TEI_Amazon_Connect/pyproject.toml
Normal file
36
studies/202602_TEI_Amazon_Connect/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 = "Amazon Connect TEI (Forrester, Feb 2026) — composite reproduction + client overlay"
|
||||
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/202602_TEI_Amazon_Connect/scripts/export_report.py
Normal file
47
studies/202602_TEI_Amazon_Connect/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/202602_TEI_Amazon_Connect/teicalc/__init__.py
Normal file
56
studies/202602_TEI_Amazon_Connect/teicalc/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
teicalc — self-contained engine for the Amazon Connect TEI study
|
||||
(Forrester, February 2026). Mercury Notebook Pattern, Variant 4:
|
||||
verbatim composite anchor → published-totals gate → client overlay.
|
||||
"""
|
||||
|
||||
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,
|
||||
growth_multiplier,
|
||||
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", "growth_multiplier", "overlay_rows",
|
||||
"SCENARIOS", "apply_scenario",
|
||||
]
|
||||
@@ -1,29 +1,24 @@
|
||||
"""
|
||||
Seed dataset for the Amazon Connect TEI (Forrester, Feb 2026).
|
||||
The verbatim anchor — Forrester *Total Economic Impact™ Of Amazon Connect*
|
||||
(February 2026, commissioned by AWS).
|
||||
|
||||
Each row uses the friendly value shape accepted by
|
||||
``core.tei_client.TEIClient.update_values`` (see ``_rows_from_value``),
|
||||
so it can be passed straight to
|
||||
``client.update_values(public_id, BENEFITS + COSTS)``.
|
||||
VERBATIM, do not edit. These are Forrester's published composite-organization
|
||||
tables and financial summary, transplanted unchanged from the study PDF
|
||||
(``docs/202602_TEI Report Amazon Connect.pdf``). Client personalization
|
||||
lives in :mod:`teicalc.overlay`; scenario stress lives in
|
||||
:mod:`teicalc.scenarios` — both deep-copy, neither mutates this record.
|
||||
|
||||
Numbers are the *nominal* (pre-risk-adjustment) values from the PDF —
|
||||
risk adjustment is stored as a factor and applied by Athena's
|
||||
calculator (or, locally, by ``core.calculations.risk_adjust_*``).
|
||||
|
||||
References for the totals (from the PDF):
|
||||
|
||||
Benefits (3-yr risk-adjusted PV @ 10%): $101,696,791
|
||||
Costs (3-yr risk-adjusted PV @ 10%): $ 22,983,076
|
||||
NPV $ 78,713,715
|
||||
ROI 342%
|
||||
Payback <6 months
|
||||
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).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: 3-year nominal benefit cashflows. Risk adjustment factor is stored
|
||||
#: separately; calculator applies it.
|
||||
BENEFITS: list[dict] = [
|
||||
#: 3-year nominal benefit cashflows — 🟢 published.
|
||||
BENEFITS_VERBATIM: list[dict] = [
|
||||
{
|
||||
"field_key": "ai_contact_resolution",
|
||||
"table": "benefits",
|
||||
@@ -95,9 +90,9 @@ BENEFITS: list[dict] = [
|
||||
]
|
||||
|
||||
|
||||
#: Costs include an "initial" (year-0, undiscounted) component for
|
||||
#: implementation. Cost risk adjustments are applied *upward*.
|
||||
COSTS: list[dict] = [
|
||||
#: Costs include an ``initial`` (year-0, undiscounted) component for
|
||||
#: implementation. Cost risk adjustments are applied *upward*. 🟢 published.
|
||||
COSTS_VERBATIM: list[dict] = [
|
||||
{
|
||||
"field_key": "amazon_connect_usage",
|
||||
"table": "costs",
|
||||
@@ -143,7 +138,7 @@ COSTS: list[dict] = [
|
||||
]
|
||||
|
||||
|
||||
#: Top-line composite assumptions — for the 03_business_case narrative.
|
||||
#: Composite-organization drivers — 🟢 published (PDF "Composite Organization").
|
||||
ASSUMPTIONS: dict = {
|
||||
"agents_fte": 2_000,
|
||||
"supervisors_fte": 200,
|
||||
@@ -158,6 +153,15 @@ ASSUMPTIONS: dict = {
|
||||
}
|
||||
|
||||
|
||||
def all_values() -> list[dict]:
|
||||
"""Return BENEFITS + COSTS — handy single-call payload for update_values."""
|
||||
return BENEFITS + COSTS
|
||||
#: The PDF's Financial Summary — the gate's reproduction target. 🟢 published.
|
||||
#: The engine reproduces these to within Forrester's own table rounding
|
||||
#: (benefits PV lands $223 low; costs PV $0.22 low).
|
||||
PUBLISHED: dict = {
|
||||
"benefits_pv": 101_696_791,
|
||||
"costs_pv": 22_983_076,
|
||||
"npv": 78_713_715,
|
||||
"roi_pct": 342,
|
||||
"payback_months_max": 6, # published as "<6 months"
|
||||
"discount_rate": 0.10,
|
||||
"analysis_years": 3,
|
||||
}
|
||||
267
studies/202602_TEI_Amazon_Connect/teicalc/model.py
Normal file
267
studies/202602_TEI_Amazon_Connect/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/202602_TEI_Amazon_Connect/teicalc/overlay.py
Normal file
107
studies/202602_TEI_Amazon_Connect/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,000 agents,
|
||||
20M contacts, 30% growth). 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. The client's growth rate
|
||||
re-bases the composite's Y1→Y3 trajectory (which embeds 30% YoY).
|
||||
|
||||
``overlay_rows(COMPOSITE)`` is the identity — it reproduces the verbatim
|
||||
numbers exactly, 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"] # 2,000 (+200 supervisors at 10:1)
|
||||
annual_contacts_y1: int = ASSUMPTIONS["annual_contacts_y1"] # 20M
|
||||
growth_rate: float = ASSUMPTIONS["growth_rate"] # 0.30 YoY
|
||||
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] = {
|
||||
"ai_contact_resolution": "contacts", # AHT × volume → contact-driven
|
||||
"ai_content_sentiment": "contacts", # per-call summaries/QA → contact-driven
|
||||
"ai_forecasting_supervision": "agents", # FTE optimization + supervisor span
|
||||
"data_driven_profit_lift": "contacts", # 🔴 proxy — revenue-driven in the PDF;
|
||||
# outbound volume is the nearest linear driver
|
||||
"legacy_solution_savings": "agents", # $/agent-month licences (supervisors follow 10:1)
|
||||
}
|
||||
COST_DRIVERS: dict[str, str] = {
|
||||
"amazon_connect_usage": "contacts", # per-minute/per-message consumption
|
||||
"implementation_migration": "fixed", # project-based — does not scale
|
||||
"ongoing_management": "fixed", # small fixed team
|
||||
}
|
||||
|
||||
|
||||
def scale_factor(driver: str, d: ClientDrivers) -> float:
|
||||
"""Linear size ratio vs the composite for one driver kind."""
|
||||
if driver == "contacts":
|
||||
return d.annual_contacts_y1 / ASSUMPTIONS["annual_contacts_y1"]
|
||||
if driver == "agents":
|
||||
return d.agents_fte / ASSUMPTIONS["agents_fte"]
|
||||
if driver == "fixed":
|
||||
return 1.0
|
||||
raise KeyError(f"Unknown driver: {driver!r}")
|
||||
|
||||
|
||||
def growth_multiplier(year_index: int, growth_rate: float) -> float:
|
||||
"""
|
||||
Re-base the composite's Y1→Y3 trajectory on the client's growth.
|
||||
|
||||
The verbatim year values already embed the composite's 30% YoY growth;
|
||||
dividing it out and compounding the client's rate preserves the
|
||||
composite's *shape* while adopting the client's slope. Year 1 → 1.0.
|
||||
"""
|
||||
composite_g = ASSUMPTIONS["growth_rate"]
|
||||
return ((1.0 + growth_rate) / (1.0 + composite_g)) ** (year_index - 1)
|
||||
|
||||
|
||||
def overlay_rows(d: ClientDrivers = COMPOSITE) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Deep-copied (benefits, costs) rows rescaled to the client's drivers.
|
||||
|
||||
Non-fixed rows: ``year_values[n] ×= scale_factor × growth_multiplier(n)``.
|
||||
Fixed rows keep their year values and ``initial`` unchanged (no growth
|
||||
re-base either — they are project/team costs, not volume costs).
|
||||
Risk factors, labels, and notes are untouched.
|
||||
"""
|
||||
|
||||
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 != "fixed":
|
||||
s = scale_factor(driver, d)
|
||||
row["year_values"] = {
|
||||
k: float(v) * s * growth_multiplier(int(k), d.growth_rate)
|
||||
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/202602_TEI_Amazon_Connect/teicalc/scenarios.py
Normal file
67
studies/202602_TEI_Amazon_Connect/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/202602_TEI_Amazon_Connect/teicalc/staging.py
Normal file
29
studies/202602_TEI_Amazon_Connect/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/202602_TEI_Amazon_Connect/tests/conftest.py
Normal file
7
studies/202602_TEI_Amazon_Connect/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))
|
||||
95
studies/202602_TEI_Amazon_Connect/tests/test_anchor.py
Normal file
95
studies/202602_TEI_Amazon_Connect/tests/test_anchor.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""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] == [
|
||||
"ai_contact_resolution",
|
||||
"ai_content_sentiment",
|
||||
"ai_forecasting_supervision",
|
||||
"data_driven_profit_lift",
|
||||
"legacy_solution_savings",
|
||||
]
|
||||
expected = {
|
||||
"ai_contact_resolution": ({"1": 13_911_040, "2": 23_932_480, "3": 37_797_760}, 0.15),
|
||||
"ai_content_sentiment": ({"1": 4_586_620, "2": 5_358_412, "3": 6_291_680}, 0.15),
|
||||
"ai_forecasting_supervision": ({"1": 6_651_680, "2": 9_133_760, "3": 12_391_712}, 0.15),
|
||||
"data_driven_profit_lift": ({"1": 1_200_000, "2": 1_560_000, "3": 2_028_000}, 0.20),
|
||||
"legacy_solution_savings": ({"1": 6_177_600, "2": 8_030_880, "3": 10_440_144}, 0.20),
|
||||
}
|
||||
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 = {
|
||||
"amazon_connect_usage": ({"1": 6_456_448, "2": 7_951_164, "3": 9_832_961}, 0.05, 0),
|
||||
"implementation_migration": ({"1": 188_333, "2": 188_333, "3": 0}, 0.10, 1_087_500),
|
||||
"ongoing_management": ({"1": 256_200, "2": 187_200, "3": 187_200}, 0.15, 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_assumptions_and_published():
|
||||
assert ASSUMPTIONS["agents_fte"] == 2_000
|
||||
assert ASSUMPTIONS["supervisors_fte"] == 200
|
||||
assert ASSUMPTIONS["annual_contacts_y1"] == 20_000_000
|
||||
assert ASSUMPTIONS["growth_rate"] == 0.30
|
||||
assert ASSUMPTIONS["discount_rate"] == 0.10
|
||||
assert ASSUMPTIONS["analysis_years"] == 3
|
||||
|
||||
assert PUBLISHED["benefits_pv"] == 101_696_791
|
||||
assert PUBLISHED["costs_pv"] == 22_983_076
|
||||
assert PUBLISHED["npv"] == 78_713_715
|
||||
assert PUBLISHED["roi_pct"] == 342
|
||||
assert PUBLISHED["payback_months_max"] == 6
|
||||
|
||||
# The composite drivers ARE the anchor assumptions.
|
||||
assert COMPOSITE.agents_fte == ASSUMPTIONS["agents_fte"]
|
||||
assert COMPOSITE.annual_contacts_y1 == ASSUMPTIONS["annual_contacts_y1"]
|
||||
assert COMPOSITE.growth_rate == ASSUMPTIONS["growth_rate"]
|
||||
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, annual_contacts_y1=1_000_000,
|
||||
growth_rate=0.0))
|
||||
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
|
||||
123
studies/202602_TEI_Amazon_Connect/tests/test_model.py
Normal file
123
studies/202602_TEI_Amazon_Connect/tests/test_model.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""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 Forrester's own table
|
||||
rounding of the published Financial Summary (benefits PV $223 low, costs PV
|
||||
$0.22 low) — pinned both engine-exact (±$1) and against PUBLISHED (±$1,000,
|
||||
the convention the retired workflow notebooks used).
|
||||
"""
|
||||
|
||||
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 = {
|
||||
"ai_contact_resolution": 51_699_826.78,
|
||||
"ai_content_sentiment": 11_326_357.54,
|
||||
"ai_forecasting_supervision": 19_469_777.37,
|
||||
"data_driven_profit_lift": 3_123_065.36,
|
||||
"legacy_solution_savings": 16_077_540.50,
|
||||
"amazon_connect_usage": 20_819_775.10,
|
||||
"implementation_migration": 1_555_794.82,
|
||||
"ongoing_management": 607_505.86,
|
||||
}
|
||||
|
||||
|
||||
@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(101_696_791, 22_983_076) == pytest.approx(342.48, abs=0.1)
|
||||
assert roi_pct(100, 0) == 0.0
|
||||
assert money(78_713_492) == "$78.7M"
|
||||
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 # gap widens, never covered
|
||||
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(0.7178) == "0.7 months (~Jan 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(101_696_567.55, abs=1)
|
||||
assert composite["costs_pv"] == pytest.approx(22_983_075.78, abs=1)
|
||||
assert composite["npv"] == pytest.approx(78_713_491.78, abs=1)
|
||||
assert composite["roi_pct"] == pytest.approx(342.4846, abs=0.01)
|
||||
assert composite["payback_months"] == pytest.approx(0.7178, abs=0.001)
|
||||
assert composite["initial_costs"] == pytest.approx(1_196_250, abs=0.01)
|
||||
|
||||
|
||||
def test_composite_reproduces_published(composite):
|
||||
assert composite["benefits_pv"] == pytest.approx(PUBLISHED["benefits_pv"], abs=1_000)
|
||||
assert composite["costs_pv"] == pytest.approx(PUBLISHED["costs_pv"], abs=1_000)
|
||||
assert composite["npv"] == pytest.approx(PUBLISHED["npv"], abs=1_000)
|
||||
assert round(composite["roi_pct"]) == PUBLISHED["roi_pct"]
|
||||
assert composite["payback_months"] < PUBLISHED["payback_months_max"]
|
||||
assert composite["payback_label"] == "0.7 months (~Jan 2026)"
|
||||
|
||||
|
||||
def test_yearly_schedules(composite):
|
||||
assert composite["benefits_by_year"][2026] == pytest.approx(27_279_019.00, abs=0.01)
|
||||
assert composite["benefits_by_year"][2027] == pytest.approx(40_333_658.20, abs=0.01)
|
||||
assert composite["benefits_by_year"][2028] == pytest.approx(57_983_494.40, abs=0.01)
|
||||
assert composite["costs_by_year"][2026] == pytest.approx(7_281_066.70, abs=0.01)
|
||||
assert composite["costs_by_year"][2027] == pytest.approx(8_771_168.50, abs=0.01)
|
||||
assert composite["costs_by_year"][2028] == pytest.approx(10_539_889.05, abs=0.01)
|
||||
assert composite["cumulative_net_by_year"][2028] == pytest.approx(97_807_797.35, 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)
|
||||
96
studies/202602_TEI_Amazon_Connect/tests/test_overlay.py
Normal file
96
studies/202602_TEI_Amazon_Connect/tests/test_overlay.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Client-overlay pins — identity at the composite, linear per-driver
|
||||
scaling, growth re-basing, and copy semantics."""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from teicalc import (
|
||||
BENEFIT_DRIVERS,
|
||||
BENEFITS_VERBATIM,
|
||||
COMPOSITE,
|
||||
COST_DRIVERS,
|
||||
COSTS_VERBATIM,
|
||||
ClientDrivers,
|
||||
compute_summary,
|
||||
growth_multiplier,
|
||||
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=1_000, annual_contacts_y1=40_000_000)
|
||||
assert scale_factor("agents", d) == pytest.approx(0.5)
|
||||
assert scale_factor("contacts", d) == pytest.approx(2.0)
|
||||
assert scale_factor("fixed", d) == 1.0
|
||||
with pytest.raises(KeyError):
|
||||
scale_factor("revenue", d)
|
||||
|
||||
|
||||
def test_half_agents_halves_agent_rows_only():
|
||||
ob, oc = overlay_rows(ClientDrivers(agents_fte=1_000))
|
||||
assert _row(ob, "ai_forecasting_supervision")["year_values"]["1"] == \
|
||||
pytest.approx(6_651_680 / 2)
|
||||
assert _row(ob, "legacy_solution_savings")["year_values"]["1"] == \
|
||||
pytest.approx(6_177_600 / 2)
|
||||
# Contact-driven and fixed rows unmoved.
|
||||
assert _row(ob, "ai_contact_resolution")["year_values"]["1"] == \
|
||||
pytest.approx(13_911_040)
|
||||
assert _row(oc, "amazon_connect_usage")["year_values"]["1"] == \
|
||||
pytest.approx(6_456_448)
|
||||
assert _row(oc, "implementation_migration")["initial"] == 1_087_500
|
||||
|
||||
|
||||
def test_double_contacts_doubles_usage_only():
|
||||
_, oc = overlay_rows(ClientDrivers(annual_contacts_y1=40_000_000))
|
||||
assert _row(oc, "amazon_connect_usage")["year_values"]["1"] == \
|
||||
pytest.approx(6_456_448 * 2)
|
||||
assert _row(oc, "implementation_migration")["year_values"]["1"] == \
|
||||
pytest.approx(188_333)
|
||||
assert _row(oc, "ongoing_management")["year_values"]["1"] == \
|
||||
pytest.approx(256_200)
|
||||
|
||||
|
||||
def test_growth_rebase():
|
||||
assert growth_multiplier(1, 0.0) == 1.0 # Y1 always 1.0
|
||||
assert growth_multiplier(2, 0.0) == pytest.approx(1 / 1.3)
|
||||
assert growth_multiplier(3, 0.0) == pytest.approx((1 / 1.3) ** 2)
|
||||
assert growth_multiplier(3, 0.30) == 1.0 # composite growth = identity
|
||||
|
||||
ob, oc = overlay_rows(ClientDrivers(growth_rate=0.0))
|
||||
row = _row(ob, "ai_contact_resolution")
|
||||
assert row["year_values"]["1"] == pytest.approx(13_911_040)
|
||||
assert row["year_values"]["2"] == pytest.approx(23_932_480 / 1.3)
|
||||
assert row["year_values"]["3"] == pytest.approx(37_797_760 / 1.3**2)
|
||||
# Fixed rows ignore the growth re-base too.
|
||||
assert _row(oc, "ongoing_management")["year_values"]["2"] == pytest.approx(187_200)
|
||||
|
||||
|
||||
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"] == 13_911_040
|
||||
assert COSTS_VERBATIM[0]["year_values"]["1"] == 6_456_448
|
||||
75
studies/202602_TEI_Amazon_Connect/tests/test_scenarios.py
Normal file
75
studies/202602_TEI_Amazon_Connect/tests/test_scenarios.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""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(71_672_867.65, abs=1)
|
||||
assert s["costs_pv"] == pytest.approx(17_437_916.34, abs=1)
|
||||
assert s["npv"] == pytest.approx(54_234_951.31, abs=1)
|
||||
assert s["roi_pct"] == pytest.approx(311.02, abs=0.01)
|
||||
assert s["payback_months"] == pytest.approx(0.763, abs=0.001)
|
||||
|
||||
|
||||
def test_aggressive_pins():
|
||||
s = _summary("aggressive")
|
||||
assert s["benefits_pv"] == pytest.approx(123_911_705.40, abs=1)
|
||||
assert s["costs_pv"] == pytest.approx(27_682_368.61, abs=1)
|
||||
assert s["npv"] == pytest.approx(96_229_336.79, abs=1)
|
||||
assert s["roi_pct"] == pytest.approx(347.62, abs=0.01)
|
||||
assert s["payback_months"] == pytest.approx(0.705, abs=0.001)
|
||||
|
||||
|
||||
def test_risk_delta_clamps_at_zero():
|
||||
"""Conservative subtracts 0.10 from cost risk; usage (0.05) clamps to 0."""
|
||||
rows = apply_scenario(COSTS_VERBATIM, "conservative")
|
||||
usage = next(r for r in rows if r["field_key"] == "amazon_connect_usage")
|
||||
assert usage["risk_adjustment"] == 0.0
|
||||
impl = next(r for r in rows if r["field_key"] == "implementation_migration")
|
||||
assert impl["risk_adjustment"] == pytest.approx(0.0) # 0.10 − 0.10
|
||||
assert impl["initial"] == pytest.approx(1_087_500 * 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"] == 13_911_040
|
||||
assert COSTS_VERBATIM[1]["initial"] == 1_087_500
|
||||
15
studies/202602_TEI_Amazon_Connect/tests/test_staging.py
Normal file
15
studies/202602_TEI_Amazon_Connect/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 == ""
|
||||
@@ -44,4 +44,4 @@ pyproject.toml # full toolchain as core deps — no requirements.txt
|
||||
```
|
||||
|
||||
Reference implementation (a full multi-notebook study):
|
||||
`studies/202512_GenesysCX/ctm-token-calculator/`.
|
||||
`studies/202607_CTM_GenesysCX/`.
|
||||
|
||||
@@ -22,11 +22,16 @@ def _env(monkeypatch):
|
||||
|
||||
@pytest.fixture
|
||||
def amazon_connect_seed():
|
||||
"""Load the Amazon Connect study's seed data."""
|
||||
sys.path.insert(0, str(ROOT / "studies" / "202602_AmazonConnect"))
|
||||
"""Load the Amazon Connect study's verbatim anchor (post-migration the
|
||||
study is self-contained; teicalc is stdlib-only, so importing it here
|
||||
needs no study venv — the row shape matches the old seed_data)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, str(ROOT / "studies" / "202602_TEI_Amazon_Connect"))
|
||||
try:
|
||||
import seed_data # type: ignore[import-not-found]
|
||||
return seed_data
|
||||
from teicalc import anchor # type: ignore[import-not-found]
|
||||
return SimpleNamespace(BENEFITS=anchor.BENEFITS_VERBATIM,
|
||||
COSTS=anchor.COSTS_VERBATIM)
|
||||
finally:
|
||||
# Leave the path alone — many tests will use the seed
|
||||
pass
|
||||
|
||||
@@ -65,7 +65,7 @@ class TestBuildReportData:
|
||||
|
||||
def test_envelope_shape(self, amazon_connect_seed):
|
||||
client = self._stub_client(amazon_connect_seed)
|
||||
env = build_report_data(client, "pid", study_slug="202602_AmazonConnect")
|
||||
env = build_report_data(client, "pid", study_slug="202602_TEI_Amazon_Connect")
|
||||
assert set(env) >= {
|
||||
"metadata",
|
||||
"report",
|
||||
@@ -75,7 +75,7 @@ class TestBuildReportData:
|
||||
"athena_export",
|
||||
"scenarios",
|
||||
}
|
||||
assert env["metadata"]["study_slug"] == "202602_AmazonConnect"
|
||||
assert env["metadata"]["study_slug"] == "202602_TEI_Amazon_Connect"
|
||||
assert env["metadata"]["proposal"] == 7
|
||||
assert env["values"]["benefits"]
|
||||
assert env["values"]["costs"]
|
||||
|
||||
Reference in New Issue
Block a user