feat: add setup notebook and update env example for Athena
This commit is contained in:
10
.env.example
10
.env.example
@@ -1,10 +1,12 @@
|
|||||||
# Athena API
|
# Copy to .env (gitignored) and fill in — or just run 00_setup.ipynb,
|
||||||
ATHENA_BASE_URL=https://athena.nttdata.com
|
# which prompts for these and writes .env for you.
|
||||||
|
ATHENA_BASE_URL=https://athena.ouranos.helu.ca
|
||||||
ATHENA_API_KEY=your-api-key-here
|
ATHENA_API_KEY=your-api-key-here
|
||||||
|
|
||||||
# Optional — pre-set the active study + tool so notebooks/CLI pick them up
|
# Optional — pre-set the active study + tool so notebooks/CLI pick them up
|
||||||
# without editing config.py.
|
# 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_REPORT_PUBLIC_ID=
|
||||||
# PALLADIUM_TOOL_PUBLIC_ID=
|
# PALLADIUM_TOOL_PUBLIC_ID=
|
||||||
# PALLADIUM_PROPOSAL_ID=
|
# PALLADIUM_PROPOSAL_ID=
|
||||||
|
# PALLADIUM_ENGAGEMENT_ID=
|
||||||
|
|||||||
235
00_setup.ipynb
Normal file
235
00_setup.ipynb
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"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."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 1,
|
||||||
|
"id": "53fcc345",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"Palladium(root='palladium', athena='not tested')"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 1,
|
||||||
|
"metadata": {},
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 2,
|
||||||
|
"id": "853aaab8",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"✅ Credentials already configured for https://athena.ouranos.helu.ca\n",
|
||||||
|
" (To rotate the key: save_credentials(api_key='new-key'))\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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'))\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "aa7464fd",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 2 · Test the connection"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 3,
|
||||||
|
"id": "9b7fcc97",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"✅ Athena connected — https://athena.ouranos.helu.ca (0 report templates visible)\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"{'status': 'ok',\n",
|
||||||
|
" 'base_url': 'https://athena.ouranos.helu.ca',\n",
|
||||||
|
" 'authenticated': True,\n",
|
||||||
|
" 'reports_found': 0,\n",
|
||||||
|
" 'timestamp': '2026-06-10T06:45:10.418874'}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 3,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"pal = init() # builds the client and pings /api/v1/tei/reports/\n",
|
||||||
|
"client = pal.client\n",
|
||||||
|
"pal.connection"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "6877d6ae",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 3 · What's in this Athena instance?"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 4,
|
||||||
|
"id": "83edbe4d",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"No TEI report templates yet — studies/202602_AmazonConnect/notebooks/00_provision.ipynb creates one.\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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.\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 5,
|
||||||
|
"id": "a247bedd",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"No TEI tool instances yet.\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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.\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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`)."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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
|
||||||
|
}
|
||||||
37
Makefile
Normal file
37
Makefile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Palladium — common commands. Run `make setup` once, then `make lab`.
|
||||||
|
|
||||||
|
VENV := .venv
|
||||||
|
PY := $(VENV)/bin/python
|
||||||
|
PIP := $(VENV)/bin/pip
|
||||||
|
|
||||||
|
.PHONY: setup lab app test lint format clean
|
||||||
|
|
||||||
|
## One-time: create venv, install deps + palladium (editable)
|
||||||
|
setup:
|
||||||
|
python3 -m venv $(VENV)
|
||||||
|
$(PIP) install --upgrade pip
|
||||||
|
$(PIP) install -r requirements.txt
|
||||||
|
$(PIP) install -e .
|
||||||
|
@echo ""
|
||||||
|
@echo "✅ Done. Next: make lab → open 00_setup.ipynb"
|
||||||
|
|
||||||
|
## Launch Jupyter Lab at the repo root (open 00_setup.ipynb first)
|
||||||
|
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
|
||||||
|
|
||||||
|
lint:
|
||||||
|
$(VENV)/bin/ruff check .
|
||||||
|
|
||||||
|
format:
|
||||||
|
$(VENV)/bin/ruff format .
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf .pytest_cache .ruff_cache **/__pycache__
|
||||||
77
README.md
77
README.md
@@ -46,53 +46,61 @@ Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start — Jupyter Lab first
|
||||||
|
|
||||||
### Prerequisites
|
Palladium is a **Jupyter Lab-first** environment. Everything starts from a
|
||||||
|
notebook; the Streamlit app and CLI are companions, not prerequisites.
|
||||||
- Python 3.11+
|
|
||||||
- Access to Athena API (API key required)
|
|
||||||
- Jupyter Lab or VS Code with notebook support
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/nttdata/palladium.git
|
git clone https://github.com/nttdata/palladium.git
|
||||||
cd palladium
|
cd palladium
|
||||||
python -m venv .venv
|
make setup # venv + deps + editable install (one time)
|
||||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
make lab # launches Jupyter Lab
|
||||||
pip install -r requirements.txt
|
```
|
||||||
|
|
||||||
|
Then open **`00_setup.ipynb`** at the repo root. It will:
|
||||||
|
|
||||||
|
1. Prompt for your Athena API key (hidden input) and save it to `.env`
|
||||||
|
2. Test the connection
|
||||||
|
3. Show what report templates and tools exist in the instance
|
||||||
|
|
||||||
|
Current target instance: **https://athena.ouranos.helu.ca** (Ouranos sandbox —
|
||||||
|
no production data, safe to experiment).
|
||||||
|
|
||||||
|
From any notebook, setup is one import:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.bootstrap import init
|
||||||
|
|
||||||
|
pal = init(study="202602_AmazonConnect") # loads .env, connects, imports study
|
||||||
|
pal.client.list_reports()
|
||||||
|
pal.seed_data.BENEFITS
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Copy the environment template and add your credentials:
|
All credentials and IDs live in `<repo>/.env` (gitignored). `00_setup.ipynb`
|
||||||
|
writes it for you; to do it by hand:
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env
|
# .env
|
||||||
ATHENA_BASE_URL=https://athena.nttdata.com
|
ATHENA_BASE_URL=https://athena.ouranos.helu.ca
|
||||||
ATHENA_API_KEY=your-api-key-here
|
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
|
### Verify Connection
|
||||||
|
|
||||||
|
In a notebook: `init()` prints the connection status. From a shell:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m palladium test
|
python -m palladium test
|
||||||
```
|
```
|
||||||
|
|
||||||
Or in Python:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from core.tei_client import TEIClient
|
|
||||||
|
|
||||||
client = TEIClient()
|
|
||||||
print(client.test_connection()) # {'status': 'ok', 'authenticated': True, ...}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -103,11 +111,12 @@ Each study lives in `studies/<slug>/`. The reference study is the
|
|||||||
February 2026 Forrester *Total Economic Impact™ Of Amazon Connect*:
|
February 2026 Forrester *Total Economic Impact™ Of Amazon Connect*:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
jupyter lab studies/202602_AmazonConnect/notebooks/
|
make lab # then browse to studies/202602_AmazonConnect/notebooks/
|
||||||
```
|
```
|
||||||
|
|
||||||
| Notebook | Purpose |
|
| 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 |
|
| `01_benefits.ipynb` | Quantify and risk-adjust benefit categories |
|
||||||
| `02_costs.ipynb` | Document implementation and ongoing costs |
|
| `02_costs.ipynb` | Document implementation and ongoing costs |
|
||||||
| `03_business_case.ipynb` | Financial summary, scenario analysis, visualizations |
|
| `03_business_case.ipynb` | Financial summary, scenario analysis, visualizations |
|
||||||
@@ -228,7 +237,10 @@ Three scenarios model uncertainty in adoption and realization
|
|||||||
|
|
||||||
```
|
```
|
||||||
palladium/
|
palladium/
|
||||||
|
├── 00_setup.ipynb # ← START HERE: credentials + connection
|
||||||
|
├── Makefile # make setup / lab / app / test
|
||||||
├── core/ # Shared, study-agnostic Python package
|
├── core/ # Shared, study-agnostic Python package
|
||||||
|
│ ├── bootstrap.py # one-import notebook setup (init, save_credentials)
|
||||||
│ ├── tei_client/ # Athena API client
|
│ ├── tei_client/ # Athena API client
|
||||||
│ │ ├── client.py # TEIClient with all /api/v1/tei/ methods
|
│ │ ├── client.py # TEIClient with all /api/v1/tei/ methods
|
||||||
│ │ └── models.py # Optional dataclasses for typed access
|
│ │ └── models.py # Optional dataclasses for typed access
|
||||||
@@ -257,6 +269,7 @@ palladium/
|
|||||||
│ ├── config.py # TOOL_PUBLIC_ID, REPORT_PUBLIC_ID
|
│ ├── config.py # TOOL_PUBLIC_ID, REPORT_PUBLIC_ID
|
||||||
│ ├── seed_data.py # 5 benefits + 3 costs from the PDF
|
│ ├── seed_data.py # 5 benefits + 3 costs from the PDF
|
||||||
│ ├── notebooks/
|
│ ├── notebooks/
|
||||||
|
│ │ ├── 00_provision.ipynb # creates template+tool in Athena, seeds & verifies
|
||||||
│ │ ├── 01_benefits.ipynb
|
│ │ ├── 01_benefits.ipynb
|
||||||
│ │ ├── 02_costs.ipynb
|
│ │ ├── 02_costs.ipynb
|
||||||
│ │ ├── 03_business_case.ipynb
|
│ │ ├── 03_business_case.ipynb
|
||||||
@@ -322,6 +335,16 @@ Authorization: Api-Key {your-api-key}
|
|||||||
|
|
||||||
API keys are provisioned in Athena's admin interface per user/service account.
|
API keys are provisioned in Athena's admin interface per user/service account.
|
||||||
|
|
||||||
|
### Methodology conventions (Palladium ↔ Athena)
|
||||||
|
|
||||||
|
Two places where the Forrester methodology and the Athena TEI API differ, and
|
||||||
|
how Palladium bridges them:
|
||||||
|
|
||||||
|
| Topic | Athena behaviour | Palladium convention |
|
||||||
|
|---|---|---|
|
||||||
|
| **Cost risk adjustment** | Costs are never risk-adjusted server-side | Cost values are pushed pre-multiplied by `(1 + risk_adj)`; field-level adjustment stays 0 |
|
||||||
|
| **Year-0 "Initial" costs** | No year-0 concept; non-annual values are folded into Year 1 | Each cost gets a companion non-annual `<key>_initial` field. `TEIClient` folds them back into an `initial` key on read. Athena discounts these as Year 1 (Forrester doesn't discount Year 0) — expect ≈0.15% drift on cost PV |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Report Pipeline Integration
|
## Report Pipeline Integration
|
||||||
|
|||||||
@@ -27,7 +27,14 @@ def value_editor(
|
|||||||
a notes column. Returns the edited DataFrame; the caller is responsible
|
a notes column. Returns the edited DataFrame; the caller is responsible
|
||||||
for converting it back to value-row dicts and PUTting to Athena.
|
for converting it back to value-row dicts and PUTting to Athena.
|
||||||
"""
|
"""
|
||||||
fields = [f for f in fields if f.get("table") == table]
|
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))
|
fields.sort(key=lambda f: int(f.get("sort_order") or 0))
|
||||||
|
|
||||||
by_key = {v.get("field_key"): v for v in values}
|
by_key = {v.get("field_key"): v for v in values}
|
||||||
|
|||||||
81
app/main.py
81
app/main.py
@@ -48,6 +48,31 @@ def _safe_call(fn, *args, **kwargs):
|
|||||||
return None
|
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:
|
def sidebar_tool_picker(client: TEIClient) -> dict | None:
|
||||||
"""Sidebar: pick an existing TEI tool or create one from a report template."""
|
"""Sidebar: pick an existing TEI tool or create one from a report template."""
|
||||||
st.sidebar.title("🛡️ Palladium")
|
st.sidebar.title("🛡️ Palladium")
|
||||||
@@ -71,24 +96,70 @@ def sidebar_tool_picker(client: TEIClient) -> dict | None:
|
|||||||
else:
|
else:
|
||||||
report_labels = {f"{r['name']} ({r['vendor']} {r['version']})": r for r in reports}
|
report_labels = {f"{r['name']} ({r['vendor']} {r['version']})": r for r in reports}
|
||||||
r_choice = st.selectbox("Report template", list(report_labels.keys()))
|
r_choice = st.selectbox("Report template", list(report_labels.keys()))
|
||||||
new_name = st.text_input("Tool name (optional)", "")
|
|
||||||
proposal_id = st.number_input(
|
# A TEI tool must attach to a Proposal OR an Engagement.
|
||||||
"Proposal ID (optional)", min_value=0, value=0, step=1
|
# 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
|
||||||
)
|
)
|
||||||
if st.button("Create"):
|
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]
|
report = report_labels[r_choice]
|
||||||
created = _safe_call(
|
created = _safe_call(
|
||||||
client.create_tool,
|
client.create_tool,
|
||||||
report_public_id=report["id"],
|
report_public_id=report["id"],
|
||||||
proposal=int(proposal_id) or None,
|
proposal=proposal_id,
|
||||||
|
engagement=engagement_id,
|
||||||
name=new_name or None,
|
name=new_name or None,
|
||||||
)
|
)
|
||||||
if created:
|
if created:
|
||||||
st.success(f"Created tool {created.get('id')}")
|
st.success(f"Created tool {created.get('id')}")
|
||||||
|
st.cache_data.clear()
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
if tool:
|
if tool:
|
||||||
st.sidebar.divider()
|
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"**Public ID**: `{tool.get('id')}`")
|
||||||
st.sidebar.markdown(f"**Status**: {tool.get('status', '?')}")
|
st.sidebar.markdown(f"**Status**: {tool.get('status', '?')}")
|
||||||
st.sidebar.markdown(f"**Version**: {tool.get('current_version', 0)}")
|
st.sidebar.markdown(f"**Version**: {tool.get('current_version', 0)}")
|
||||||
|
|||||||
@@ -27,9 +27,14 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
st.error(f"Athena API error: {e.detail}")
|
st.error(f"Athena API error: {e.detail}")
|
||||||
return
|
return
|
||||||
|
|
||||||
npv = float(summary.get("npv") or 0)
|
npv = float(summary.get("net_present_value") or summary.get("npv") or 0)
|
||||||
roi = float(summary.get("roi") or summary.get("roi_pct") or 0)
|
roi = float(
|
||||||
payback = summary.get("payback_months")
|
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)
|
bpv = float(summary.get("total_benefits_pv") or 0)
|
||||||
cpv = float(summary.get("total_costs_pv") or 0)
|
cpv = float(summary.get("total_costs_pv") or 0)
|
||||||
|
|
||||||
@@ -45,7 +50,16 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
|
# Build the yearly breakdown from the documented per-year summary keys
|
||||||
|
# (benefits_year_N / costs_year_N) when no pre-built breakdown exists.
|
||||||
yb = summary.get("yearly_breakdown") or []
|
yb = summary.get("yearly_breakdown") or []
|
||||||
|
if not yb:
|
||||||
|
n = 1
|
||||||
|
while f"benefits_year_{n}" in summary or f"costs_year_{n}" in summary:
|
||||||
|
b = float(summary.get(f"benefits_year_{n}") or 0)
|
||||||
|
c = float(summary.get(f"costs_year_{n}") or 0)
|
||||||
|
yb.append({"year": n, "benefits": b, "costs": c, "net": b - c})
|
||||||
|
n += 1
|
||||||
initial = float(summary.get("initial_costs") or 0)
|
initial = float(summary.get("initial_costs") or 0)
|
||||||
if yb:
|
if yb:
|
||||||
charts.cashflow(yb, initial_cost=initial)
|
charts.cashflow(yb, initial_cost=initial)
|
||||||
|
|||||||
@@ -17,11 +17,24 @@ def _diff_rows(a: dict[str, dict], b: dict[str, dict]) -> list[dict]:
|
|||||||
"""Return one row per field with side-by-side year values."""
|
"""Return one row per field with side-by-side year values."""
|
||||||
keys = sorted(set(a.keys()) | set(b.keys()))
|
keys = sorted(set(a.keys()) | set(b.keys()))
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
|
def _years_of(v: dict) -> dict:
|
||||||
|
"""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:
|
for k in keys:
|
||||||
av = a.get(k, {}) or {}
|
av = a.get(k, {}) or {}
|
||||||
bv = b.get(k, {}) or {}
|
bv = b.get(k, {}) or {}
|
||||||
ay = av.get("year_values") or {}
|
ay = _years_of(av)
|
||||||
by = bv.get("year_values") or {}
|
by = _years_of(bv)
|
||||||
years = sorted(set(ay.keys()) | set(by.keys()), key=lambda x: int(x))
|
years = sorted(set(ay.keys()) | set(by.keys()), key=lambda x: int(x))
|
||||||
for y in years:
|
for y in years:
|
||||||
a_val = float(ay.get(y) or 0)
|
a_val = float(ay.get(y) or 0)
|
||||||
@@ -79,8 +92,13 @@ def render(client: TEIClient, tool: dict) -> None:
|
|||||||
{
|
{
|
||||||
"Version": v.get("version_number"),
|
"Version": v.get("version_number"),
|
||||||
"Date": v.get("created_at") or v.get("date"),
|
"Date": v.get("created_at") or v.get("date"),
|
||||||
"NPV": float(snap.get("npv") or 0),
|
"NPV": float(snap.get("net_present_value") or snap.get("npv") or 0),
|
||||||
"ROI %": float(snap.get("roi") or snap.get("roi_pct") or 0),
|
"ROI %": float(
|
||||||
|
snap.get("roi_percentage")
|
||||||
|
or snap.get("roi")
|
||||||
|
or snap.get("roi_pct")
|
||||||
|
or 0
|
||||||
|
),
|
||||||
"Note": v.get("note", ""),
|
"Note": v.get("note", ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
184
core/bootstrap.py
Normal file
184
core/bootstrap.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
Palladium notebook bootstrap — one import to set everything up.
|
||||||
|
|
||||||
|
From *any* notebook in the repo (root, ``studies/<slug>/notebooks/``, …)::
|
||||||
|
|
||||||
|
from core.bootstrap import init
|
||||||
|
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
|
||||||
|
|
||||||
|
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::
|
||||||
|
|
||||||
|
import sys, pathlib
|
||||||
|
sys.path.insert(0, str(next(p for p in pathlib.Path.cwd().parents
|
||||||
|
if (p / "pyproject.toml").exists())))
|
||||||
|
|
||||||
|
Better: ``pip install -e .`` once (``make setup`` does this) and even that
|
||||||
|
two-liner is unnecessary.
|
||||||
|
|
||||||
|
Credentials live in ``<repo root>/.env`` (gitignored)::
|
||||||
|
|
||||||
|
ATHENA_BASE_URL=https://athena.ouranos.helu.ca
|
||||||
|
ATHENA_API_KEY=...
|
||||||
|
|
||||||
|
``save_credentials()`` writes that file for you — 00_setup.ipynb uses it with
|
||||||
|
``getpass`` so the key never lands in notebook output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
__all__ = ["init", "find_root", "save_credentials", "update_env", "Palladium"]
|
||||||
|
|
||||||
|
_ROOT_MARKERS = ("pyproject.toml", ".git")
|
||||||
|
|
||||||
|
|
||||||
|
def find_root(start: Path | None = None) -> Path:
|
||||||
|
"""Locate the repo root by walking up until pyproject.toml/.git is found."""
|
||||||
|
candidates = [start] if start else [Path.cwd(), Path(__file__).resolve().parent]
|
||||||
|
for cand in candidates:
|
||||||
|
for p in [cand, *cand.parents]:
|
||||||
|
if any((p / m).exists() for m in _ROOT_MARKERS):
|
||||||
|
return p
|
||||||
|
return Path.cwd() # pragma: no cover — degenerate fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_importable(root: Path) -> None:
|
||||||
|
if str(root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(root))
|
||||||
|
|
||||||
|
|
||||||
|
def update_env(root: Path | None = None, **pairs: str) -> Path:
|
||||||
|
"""
|
||||||
|
Write (or update) keys in ``<root>/.env``, preserving all other lines.
|
||||||
|
|
||||||
|
Also updates ``os.environ`` so the values take effect in the running
|
||||||
|
kernel immediately. Returns the path to the .env file.
|
||||||
|
"""
|
||||||
|
root = root or find_root()
|
||||||
|
env_path = root / ".env"
|
||||||
|
updates = {k: str(v) for k, v in pairs.items()}
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
if env_path.exists():
|
||||||
|
lines = env_path.read_text().splitlines()
|
||||||
|
seen: set[str] = set()
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
key = line.split("=", 1)[0].strip().lstrip("# ").strip()
|
||||||
|
if key in updates:
|
||||||
|
lines[i] = f"{key}={updates[key]}"
|
||||||
|
seen.add(key)
|
||||||
|
for key, val in updates.items():
|
||||||
|
if key not in seen:
|
||||||
|
lines.append(f"{key}={val}")
|
||||||
|
env_path.write_text("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
os.environ.update(updates)
|
||||||
|
return env_path
|
||||||
|
|
||||||
|
|
||||||
|
def save_credentials(
|
||||||
|
api_key: str,
|
||||||
|
base_url: str = "https://athena.ouranos.helu.ca",
|
||||||
|
root: Path | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Write (or update) ``<root>/.env`` with Athena credentials."""
|
||||||
|
return update_env(
|
||||||
|
root, ATHENA_BASE_URL=base_url.rstrip("/"), ATHENA_API_KEY=api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Palladium:
|
||||||
|
"""Everything a notebook session needs, in one object."""
|
||||||
|
|
||||||
|
root: Path
|
||||||
|
client: Any = None
|
||||||
|
config: ModuleType | None = None
|
||||||
|
seed_data: ModuleType | None = None
|
||||||
|
connection: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
status = self.connection.get("status", "not tested")
|
||||||
|
study = getattr(self.config, "STUDY_SLUG", None)
|
||||||
|
return (
|
||||||
|
f"Palladium(root={self.root.name!r}, athena={status!r}"
|
||||||
|
+ (f", study={study!r}" if study else "")
|
||||||
|
+ ")"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init(
|
||||||
|
study: str | None = None,
|
||||||
|
connect: bool = True,
|
||||||
|
quiet: bool = False,
|
||||||
|
) -> Palladium:
|
||||||
|
"""
|
||||||
|
One-call notebook setup.
|
||||||
|
|
||||||
|
1. Finds the repo root and makes ``core``/``studies`` importable.
|
||||||
|
2. Loads ``<root>/.env``.
|
||||||
|
3. Builds a :class:`TEIClient` and tests the connection (``connect=True``).
|
||||||
|
4. Optionally imports a study's ``config`` and ``seed_data`` modules.
|
||||||
|
|
||||||
|
Returns a :class:`Palladium` namespace: ``pal.client``, ``pal.config``,
|
||||||
|
``pal.seed_data``, ``pal.root``, ``pal.connection``.
|
||||||
|
"""
|
||||||
|
root = find_root()
|
||||||
|
_ensure_importable(root)
|
||||||
|
load_dotenv(root / ".env")
|
||||||
|
|
||||||
|
pal = Palladium(root=root)
|
||||||
|
|
||||||
|
if study:
|
||||||
|
pal.config = importlib.import_module(f"studies.{study}.config")
|
||||||
|
try:
|
||||||
|
pal.seed_data = importlib.import_module(f"studies.{study}.seed_data")
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pal.seed_data = None
|
||||||
|
|
||||||
|
if connect:
|
||||||
|
from core.tei_client import TEIClient
|
||||||
|
|
||||||
|
try:
|
||||||
|
pal.client = TEIClient()
|
||||||
|
pal.connection = pal.client.test_connection()
|
||||||
|
except ValueError as e: # missing credentials
|
||||||
|
pal.connection = {"status": "unconfigured", "detail": str(e)}
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
c = pal.connection
|
||||||
|
if c.get("status") == "ok":
|
||||||
|
print(
|
||||||
|
f"✅ Athena connected — {c['base_url']} "
|
||||||
|
f"({c.get('reports_found', '?')} report templates visible)"
|
||||||
|
)
|
||||||
|
elif c.get("status") == "unconfigured":
|
||||||
|
print(
|
||||||
|
"⚠️ No credentials. Run 00_setup.ipynb, or:\n"
|
||||||
|
" from core.bootstrap import save_credentials\n"
|
||||||
|
" save_credentials(api_key='…')"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"❌ Athena connection failed "
|
||||||
|
f"({c.get('error_code')}): {c.get('detail')}"
|
||||||
|
)
|
||||||
|
if not quiet and study and pal.config is not None:
|
||||||
|
print(f"📁 Study: {study}")
|
||||||
|
return pal
|
||||||
@@ -78,8 +78,8 @@ def apply_scenario(
|
|||||||
- ``initial`` (optional, costs only) — scaled by adoption.
|
- ``initial`` (optional, costs only) — scaled by adoption.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
items: rows shaped like the ``_normalize_value`` output of
|
items: friendly value rows (``year_values`` / ``value`` / ``initial``)
|
||||||
:class:`core.tei_client.TEIClient`.
|
as returned by :meth:`core.tei_client.TEIClient.get_values`.
|
||||||
scenario: key into ``multipliers`` (default ``SCENARIOS``).
|
scenario: key into ``multipliers`` (default ``SCENARIOS``).
|
||||||
multipliers: override map. Same shape as ``SCENARIOS``.
|
multipliers: override map. Same shape as ``SCENARIOS``.
|
||||||
table: force a table when items lack one.
|
table: force a table when items lack one.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Pandas dataframe builders for benefit / cost / summary tables.
|
Pandas dataframe builders for benefit / cost / summary tables.
|
||||||
|
|
||||||
Each builder accepts the value-row dicts produced by
|
Each builder accepts the friendly value-row dicts returned by
|
||||||
``core.tei_client.TEIClient._normalize_value`` and returns a
|
``core.tei_client.TEIClient.get_values`` and returns a
|
||||||
nicely-formatted DataFrame for display in notebooks.
|
nicely-formatted DataFrame for display in notebooks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ load_dotenv()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
API_PREFIX = "/api/v1/tei"
|
API_PREFIX = "/api/v1/tei"
|
||||||
|
ORBIT_PREFIX = "/api/v1/orbit"
|
||||||
|
ENGAGEMENT_PREFIX = "/api/v1/engagement"
|
||||||
|
|
||||||
|
|
||||||
class AthenaAPIError(Exception):
|
class AthenaAPIError(Exception):
|
||||||
@@ -242,6 +244,34 @@ class TEIClient:
|
|||||||
"""Get a TEI report template by its public_id."""
|
"""Get a TEI report template by its public_id."""
|
||||||
return self._get(f"{API_PREFIX}/reports/{public_id}/")
|
return self._get(f"{API_PREFIX}/reports/{public_id}/")
|
||||||
|
|
||||||
|
def create_report(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
vendor: str,
|
||||||
|
version: str = "1.0",
|
||||||
|
description: str = "",
|
||||||
|
analysis_period_years: int = 3,
|
||||||
|
discount_rate: float | str = "0.10",
|
||||||
|
status: str = "draft",
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new TEI report template (admin only)."""
|
||||||
|
return self._post(
|
||||||
|
f"{API_PREFIX}/reports/",
|
||||||
|
data={
|
||||||
|
"name": name,
|
||||||
|
"vendor": vendor,
|
||||||
|
"version": version,
|
||||||
|
"description": description,
|
||||||
|
"analysis_period_years": analysis_period_years,
|
||||||
|
"discount_rate": str(discount_rate),
|
||||||
|
"status": status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_report(self, public_id: str, **changes) -> dict:
|
||||||
|
"""Patch report template metadata (e.g. ``status='active'``)."""
|
||||||
|
return self._patch(f"{API_PREFIX}/reports/{public_id}/", data=changes)
|
||||||
|
|
||||||
def list_fields(
|
def list_fields(
|
||||||
self,
|
self,
|
||||||
report_public_id: str,
|
report_public_id: str,
|
||||||
@@ -286,10 +316,18 @@ class TEIClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def reorder_fields(self, report_public_id: str, field_ids: list[int]) -> dict:
|
def reorder_fields(self, report_public_id: str, field_ids: list[int]) -> dict:
|
||||||
"""Bulk-reorder fields. Spec: PATCH /reports/{id}/fields/reorder/."""
|
"""
|
||||||
|
Bulk-reorder fields. Spec body: ``{"field_order": [{"id", "sort_order"}]}``.
|
||||||
|
``field_ids`` is the desired order; sort_order is assigned 1..N.
|
||||||
|
"""
|
||||||
return self._patch(
|
return self._patch(
|
||||||
f"{API_PREFIX}/reports/{report_public_id}/fields/reorder/",
|
f"{API_PREFIX}/reports/{report_public_id}/fields/reorder/",
|
||||||
data={"field_ids": field_ids},
|
data={
|
||||||
|
"field_order": [
|
||||||
|
{"id": fid, "sort_order": i + 1}
|
||||||
|
for i, fid in enumerate(field_ids)
|
||||||
|
]
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@@ -345,96 +383,338 @@ class TEIClient:
|
|||||||
def delete_tool(self, public_id: str) -> dict:
|
def delete_tool(self, public_id: str) -> dict:
|
||||||
return self._delete(f"{API_PREFIX}/tools/{public_id}/")
|
return self._delete(f"{API_PREFIX}/tools/{public_id}/")
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# CRM context — clients, opportunities, proposals, engagements
|
||||||
|
#
|
||||||
|
# A TEI tool must attach to a Proposal OR an Engagement. These methods
|
||||||
|
# let Palladium browse the CRM (Athena's "Orbit" module) so the user
|
||||||
|
# selects an existing record — and client data (industry, agent counts,
|
||||||
|
# revenue) flows into the study without re-entry.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_clients(self, search: str | None = None) -> list[dict]:
|
||||||
|
"""List CRM clients, optionally filtered by name/legal name/overview."""
|
||||||
|
params = {"search": search} if search else None
|
||||||
|
return self._paginated(f"{ORBIT_PREFIX}/clients/", params=params)
|
||||||
|
|
||||||
|
def get_client(self, client_id: int) -> dict:
|
||||||
|
"""Full client record — vertical, employee_count, revenue,
|
||||||
|
contact_center_agent_count, supervisor_count, location_count, …"""
|
||||||
|
return self._get(f"{ORBIT_PREFIX}/clients/{int(client_id)}/")
|
||||||
|
|
||||||
|
def client_profile(self, client_id: int) -> dict:
|
||||||
|
"""The TEI-relevant subset of a client record (for assumptions)."""
|
||||||
|
c = self.get_client(client_id)
|
||||||
|
keys = (
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"abbreviated_name",
|
||||||
|
"vertical",
|
||||||
|
"client_type",
|
||||||
|
"employee_count",
|
||||||
|
"revenue",
|
||||||
|
"contact_center_agent_count",
|
||||||
|
"service_desk_agent_count",
|
||||||
|
"supervisor_count",
|
||||||
|
"location_count",
|
||||||
|
)
|
||||||
|
return {k: c.get(k) for k in keys}
|
||||||
|
|
||||||
|
def list_opportunities(self, search: str | None = None) -> list[dict]:
|
||||||
|
"""List opportunities (each embeds its read-only ``client``)."""
|
||||||
|
params = {"search": search} if search else None
|
||||||
|
return self._paginated(f"{ORBIT_PREFIX}/opportunities/", params=params)
|
||||||
|
|
||||||
|
def create_opportunity(self, name: str, client_id: int, **extra) -> dict:
|
||||||
|
"""Create an opportunity for a client (sandbox/demo convenience)."""
|
||||||
|
return self._post(
|
||||||
|
f"{ORBIT_PREFIX}/opportunities/",
|
||||||
|
data={"name": name, "client_id": int(client_id), **extra},
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_proposals(
|
||||||
|
self,
|
||||||
|
opportunity_id: int | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List proposals (each embeds ``opportunity`` → ``client``)."""
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if opportunity_id is not None:
|
||||||
|
params["opportunity_id"] = int(opportunity_id)
|
||||||
|
if status:
|
||||||
|
params["status"] = status
|
||||||
|
if search:
|
||||||
|
params["search"] = search
|
||||||
|
return self._paginated(f"{ORBIT_PREFIX}/proposals/", params=params or None)
|
||||||
|
|
||||||
|
def create_proposal(
|
||||||
|
self, name: str, opportunity_id: int, status: str = "Draft", **extra
|
||||||
|
) -> dict:
|
||||||
|
"""Create a proposal under an opportunity."""
|
||||||
|
return self._post(
|
||||||
|
f"{ORBIT_PREFIX}/proposals/",
|
||||||
|
data={
|
||||||
|
"name": name,
|
||||||
|
"opportunity_id": int(opportunity_id),
|
||||||
|
"status": status,
|
||||||
|
**extra,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_engagements(self, search: str | None = None) -> list[dict]:
|
||||||
|
"""List engagements (summary rows include ``client_name``)."""
|
||||||
|
params = {"search": search} if search else None
|
||||||
|
return self._paginated(f"{ENGAGEMENT_PREFIX}/engagements/", params=params)
|
||||||
|
|
||||||
|
def proposals_for_client(self, client_id: int) -> list[dict]:
|
||||||
|
"""All proposals whose opportunity belongs to ``client_id``."""
|
||||||
|
out = []
|
||||||
|
for p in self.list_proposals():
|
||||||
|
opp = p.get("opportunity") or {}
|
||||||
|
cli = opp.get("client") or {}
|
||||||
|
if cli.get("id") == int(client_id):
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def engagements_for_client(self, client_name: str) -> list[dict]:
|
||||||
|
"""All engagements matching a client's name (summary list filter)."""
|
||||||
|
return [
|
||||||
|
e
|
||||||
|
for e in self.list_engagements(search=client_name)
|
||||||
|
if (e.get("client_name") or "").lower() == client_name.lower()
|
||||||
|
]
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Values (data entry)
|
# Values (data entry)
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
#: Suffix used for companion non-annual fields that hold a cost's
|
||||||
|
#: Year-0 "Initial" amount (the TEI API has no native year-0 concept).
|
||||||
|
INITIAL_SUFFIX = "_initial"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_value(value: dict) -> dict:
|
def _coerce_float(raw: Any, default: float = 0.0) -> float:
|
||||||
|
try:
|
||||||
|
return float(raw) if raw is not None and raw != "" else default
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _rows_from_value(cls, value: dict) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Normalize a value-row dict into the shape the API expects.
|
Expand one friendly value dict into wire-format rows for the bulk
|
||||||
|
``PUT /values/`` endpoint (one row per field/year, per Athena_TEI.md).
|
||||||
|
|
||||||
Accepts any of the following input forms and produces a uniform
|
Accepted input forms::
|
||||||
wire-format dict::
|
|
||||||
|
|
||||||
# annual fields
|
# annual fields (either shorthand)
|
||||||
{"field_key": "A1", "year_1": 100, "year_2": 200, "year_3": 300, ...}
|
{"field_key": "A1", "year_1": 100, "year_2": 200, ...}
|
||||||
{"field_key": "A1", "year_values": {"1": 100, "2": 200, "3": 300}, ...}
|
{"field_key": "A1", "year_values": {"1": 100, "2": 200}, ...}
|
||||||
|
|
||||||
# non-annual scalars
|
# non-annual scalars
|
||||||
{"field_key": "rate", "value": 0.10, ...}
|
{"field_key": "rate", "value": 0.10, ...}
|
||||||
|
|
||||||
Returns a dict like::
|
# costs with a Year-0 component → companion "<key>_initial"
|
||||||
|
# non-annual field (must exist on the report; see provisioning)
|
||||||
|
{"field_key": "impl", "initial": 1_000_000, "year_values": {...}}
|
||||||
|
|
||||||
{"field_key": "A1",
|
Output rows look like::
|
||||||
"year_values": {"1": 100.0, "2": 200.0, "3": 300.0},
|
|
||||||
"risk_adjustment": 0.15,
|
{"field_key": "A1", "year": 1, "value": "100", ...}
|
||||||
"notes": "…"}
|
{"field_key": "rate", "year": None, "value": "0.10", ...}
|
||||||
"""
|
"""
|
||||||
out: dict[str, Any] = {}
|
field_key = value.get("field_key") or value.get("field")
|
||||||
if "field_key" in value:
|
if not field_key:
|
||||||
out["field_key"] = value["field_key"]
|
return []
|
||||||
elif "field" in value:
|
|
||||||
out["field_key"] = value["field"]
|
|
||||||
|
|
||||||
# Collect annual year_N keys into year_values
|
year_values: dict[int, float] = {}
|
||||||
year_values: dict[str, float] = {}
|
if isinstance(value.get("year_values"), dict):
|
||||||
if "year_values" in value and isinstance(value["year_values"], dict):
|
|
||||||
for k, v in value["year_values"].items():
|
for k, v in value["year_values"].items():
|
||||||
year_values[str(k)] = float(v) if v is not None else 0.0
|
year_values[int(k)] = cls._coerce_float(v)
|
||||||
for key, raw in value.items():
|
for key, raw in value.items():
|
||||||
if key.startswith("year_"):
|
if key.startswith("year_"):
|
||||||
try:
|
try:
|
||||||
n = int(key.split("_", 1)[1])
|
n = int(key.split("_", 1)[1])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
year_values[str(n)] = float(raw) if raw is not None else 0.0
|
year_values[n] = cls._coerce_float(raw)
|
||||||
|
|
||||||
|
risk_adjustment = (
|
||||||
|
float(value["risk_adjustment"])
|
||||||
|
if value.get("risk_adjustment") is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
notes = str(value["notes"]) if value.get("notes") else None
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
if year_values:
|
if year_values:
|
||||||
out["year_values"] = year_values
|
for i, year in enumerate(sorted(year_values)):
|
||||||
if "value" in value and value["value"] is not None and not year_values:
|
row: dict[str, Any] = {
|
||||||
out["value"] = value["value"]
|
"field_key": field_key,
|
||||||
|
"year": year,
|
||||||
|
"value": str(year_values[year]),
|
||||||
|
"risk_adjustment": (
|
||||||
|
str(risk_adjustment) if risk_adjustment is not None else None
|
||||||
|
),
|
||||||
|
# Attach narrative notes to the first year row only.
|
||||||
|
"notes": notes if i == 0 else None,
|
||||||
|
}
|
||||||
|
rows.append(row)
|
||||||
|
elif value.get("value") is not None:
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"field_key": field_key,
|
||||||
|
"year": None,
|
||||||
|
"value": str(value["value"]),
|
||||||
|
"risk_adjustment": (
|
||||||
|
str(risk_adjustment) if risk_adjustment is not None else None
|
||||||
|
),
|
||||||
|
"notes": notes,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Year-0 "Initial" amount → companion non-annual field.
|
||||||
if value.get("initial") is not None:
|
if value.get("initial") is not None:
|
||||||
out["initial"] = float(value["initial"])
|
rows.append(
|
||||||
if value.get("risk_adjustment") is not None:
|
{
|
||||||
out["risk_adjustment"] = float(value["risk_adjustment"])
|
"field_key": f"{field_key}{cls.INITIAL_SUFFIX}",
|
||||||
if value.get("notes"):
|
"year": None,
|
||||||
out["notes"] = str(value["notes"])
|
"value": str(cls._coerce_float(value["initial"])),
|
||||||
|
"risk_adjustment": None,
|
||||||
|
"notes": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _friendly_value_row(cls, raw: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Convert one GET ``/values/`` row (documented shape) into the friendly
|
||||||
|
internal shape used by the notebooks and the Streamlit app::
|
||||||
|
|
||||||
|
{"field_key", "label", "table", "category", "field_type",
|
||||||
|
"is_annual", "risk_adjustment", "year_values": {"1": 100.0, ...},
|
||||||
|
"value": 0.10, "notes": "…"}
|
||||||
|
"""
|
||||||
|
out = {
|
||||||
|
k: raw.get(k)
|
||||||
|
for k in (
|
||||||
|
"id",
|
||||||
|
"field_key",
|
||||||
|
"label",
|
||||||
|
"table",
|
||||||
|
"category",
|
||||||
|
"field_type",
|
||||||
|
"is_annual",
|
||||||
|
"notes",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
out["risk_adjustment"] = (
|
||||||
|
cls._coerce_float(raw.get("risk_adjustment"), 0.0)
|
||||||
|
if raw.get("risk_adjustment") is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
years = raw.get("years")
|
||||||
|
if isinstance(years, dict): # documented annual shape
|
||||||
|
year_values: dict[str, float] = {}
|
||||||
|
notes_parts: list[str] = []
|
||||||
|
for y in sorted(years, key=int):
|
||||||
|
cell = years[y] or {}
|
||||||
|
year_values[str(int(y))] = cls._coerce_float(cell.get("value"))
|
||||||
|
if cell.get("risk_adjustment") is not None:
|
||||||
|
out["risk_adjustment"] = cls._coerce_float(
|
||||||
|
cell["risk_adjustment"], out["risk_adjustment"] or 0.0
|
||||||
|
)
|
||||||
|
if cell.get("notes"):
|
||||||
|
notes_parts.append(str(cell["notes"]))
|
||||||
|
out["year_values"] = year_values
|
||||||
|
if notes_parts and not out.get("notes"):
|
||||||
|
out["notes"] = " | ".join(notes_parts)
|
||||||
|
elif isinstance(raw.get("year_values"), dict): # already friendly
|
||||||
|
out["year_values"] = {
|
||||||
|
str(int(k)): cls._coerce_float(v)
|
||||||
|
for k, v in raw["year_values"].items()
|
||||||
|
}
|
||||||
|
else: # non-annual scalar
|
||||||
|
out["value"] = cls._coerce_float(raw.get("value"))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def get_values(self, public_id: str) -> list[dict]:
|
def get_values(self, public_id: str) -> list[dict]:
|
||||||
"""Get all current field values for a TEI tool instance."""
|
"""
|
||||||
|
Get all current field values for a TEI tool instance.
|
||||||
|
|
||||||
|
Returns friendly rows (see ``_friendly_value_row``). Companion
|
||||||
|
``*_initial`` fields are folded into their parent cost row as an
|
||||||
|
``initial`` key rather than returned as standalone rows.
|
||||||
|
"""
|
||||||
result = self._get(f"{API_PREFIX}/tools/{public_id}/values/")
|
result = self._get(f"{API_PREFIX}/tools/{public_id}/values/")
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
# Could be {"values": [...]} envelope, the TEITool wrapper, or a page
|
raw_rows = (
|
||||||
if "values" in result and isinstance(result["values"], list):
|
result.get("values")
|
||||||
return result["values"]
|
or result.get("results")
|
||||||
if "results" in result and isinstance(result["results"], list):
|
or []
|
||||||
return result["results"]
|
)
|
||||||
return []
|
elif isinstance(result, list):
|
||||||
if isinstance(result, list):
|
raw_rows = result
|
||||||
return result
|
else:
|
||||||
return []
|
raw_rows = []
|
||||||
|
|
||||||
|
rows = [self._friendly_value_row(r) for r in raw_rows if isinstance(r, dict)]
|
||||||
|
|
||||||
|
# Fold "<key>_initial" companions into their parent row.
|
||||||
|
by_key = {r["field_key"]: r for r in rows if r.get("field_key")}
|
||||||
|
folded: list[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
fk = row.get("field_key") or ""
|
||||||
|
if fk.endswith(self.INITIAL_SUFFIX):
|
||||||
|
parent = by_key.get(fk[: -len(self.INITIAL_SUFFIX)])
|
||||||
|
if parent is not None:
|
||||||
|
parent["initial"] = row.get("value", 0.0)
|
||||||
|
continue # folded — don't emit standalone
|
||||||
|
folded.append(row)
|
||||||
|
return folded
|
||||||
|
|
||||||
def update_values(self, public_id: str, values: list[dict]) -> dict:
|
def update_values(self, public_id: str, values: list[dict]) -> dict:
|
||||||
"""
|
"""
|
||||||
Bulk-update field values. See ``_normalize_value`` for accepted shapes.
|
Bulk-update field values. Accepts friendly dicts (``year_values`` /
|
||||||
|
``year_N`` / ``value`` / ``initial``); see ``_rows_from_value``.
|
||||||
"""
|
"""
|
||||||
payload = {"values": [self._normalize_value(v) for v in values]}
|
rows: list[dict] = []
|
||||||
return self._put(f"{API_PREFIX}/tools/{public_id}/values/", data=payload)
|
for v in values:
|
||||||
|
rows.extend(self._rows_from_value(v))
|
||||||
def patch_value(self, public_id: str, field_key: str, **changes) -> dict:
|
return self._put(
|
||||||
"""
|
f"{API_PREFIX}/tools/{public_id}/values/", data={"values": rows}
|
||||||
Patch a single field value by its ``field_key``.
|
|
||||||
|
|
||||||
Accepts the same shorthand as ``update_values`` (``year_1=…``, etc).
|
|
||||||
"""
|
|
||||||
body = self._normalize_value({"field_key": field_key, **changes})
|
|
||||||
body.pop("field_key", None) # carried in URL
|
|
||||||
return self._patch(
|
|
||||||
f"{API_PREFIX}/tools/{public_id}/values/{field_key}/", data=body
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def patch_value(
|
||||||
|
self,
|
||||||
|
public_id: str,
|
||||||
|
field_key: str,
|
||||||
|
year: int | None = None,
|
||||||
|
value: Any | None = None,
|
||||||
|
risk_adjustment: float | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Patch a single field value.
|
||||||
|
|
||||||
|
``year`` is required for annual fields (passed as a query param per
|
||||||
|
the API spec); omit it for non-annual fields.
|
||||||
|
"""
|
||||||
|
body: dict[str, Any] = {}
|
||||||
|
if value is not None:
|
||||||
|
body["value"] = str(value)
|
||||||
|
if risk_adjustment is not None:
|
||||||
|
body["risk_adjustment"] = str(risk_adjustment)
|
||||||
|
if notes is not None:
|
||||||
|
body["notes"] = notes
|
||||||
|
path = f"{API_PREFIX}/tools/{public_id}/values/{field_key}/"
|
||||||
|
if year is not None:
|
||||||
|
path = f"{path}?year={int(year)}"
|
||||||
|
return self._patch(path, data=body)
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Calculation & summary
|
# Calculation & summary
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@@ -467,11 +747,18 @@ class TEIClient:
|
|||||||
return result["versions"]
|
return result["versions"]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def save_version(self, public_id: str, note: str = "") -> dict:
|
def save_version(
|
||||||
"""Snapshot current values + summary as a new version."""
|
self, public_id: str, note: str = "", date: str | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Snapshot current values + summary as a new version.
|
||||||
|
|
||||||
|
``date`` defaults to today (ISO ``YYYY-MM-DD``); the API requires it.
|
||||||
|
Saving a version auto-triggers ``/calculate/`` server-side.
|
||||||
|
"""
|
||||||
return self._post(
|
return self._post(
|
||||||
f"{API_PREFIX}/tools/{public_id}/versions/",
|
f"{API_PREFIX}/tools/{public_id}/versions/",
|
||||||
data={"note": note},
|
data={"date": date or datetime.now().strftime("%Y-%m-%d"), "note": note},
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_version(self, public_id: str, version_number: int) -> dict:
|
def get_version(self, public_id: str, version_number: int) -> dict:
|
||||||
@@ -555,9 +842,11 @@ class TEIClient:
|
|||||||
print(f" Total Benefits (PV): ${_f(s.get('total_benefits_pv')):>16,.0f}")
|
print(f" Total Benefits (PV): ${_f(s.get('total_benefits_pv')):>16,.0f}")
|
||||||
print(f" Total Costs (PV): ${_f(s.get('total_costs_pv')):>16,.0f}")
|
print(f" Total Costs (PV): ${_f(s.get('total_costs_pv')):>16,.0f}")
|
||||||
print("─" * 56)
|
print("─" * 56)
|
||||||
print(f" Net Present Value: ${_f(s.get('npv')):>16,.0f}")
|
npv = s.get("net_present_value", s.get("npv"))
|
||||||
print(f" ROI: {_f(s.get('roi')):>15,.0f}%")
|
roi = s.get("roi_percentage", s.get("roi"))
|
||||||
payback = s.get("payback_months")
|
print(f" Net Present Value: ${_f(npv):>16,.0f}")
|
||||||
|
print(f" ROI: {_f(roi):>15,.0f}%")
|
||||||
|
payback = s.get("payback_period_months", s.get("payback_months"))
|
||||||
payback_str = f"{_f(payback):.1f} months" if payback is not None else "N/A"
|
payback_str = f"{_f(payback):.1f} months" if payback is not None else "N/A"
|
||||||
print(f" Payback: {payback_str:>17}")
|
print(f" Payback: {payback_str:>17}")
|
||||||
print("═" * 56)
|
print("═" * 56)
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ class TEIValue:
|
|||||||
"""
|
"""
|
||||||
A field value for a specific TEI tool instance.
|
A field value for a specific TEI tool instance.
|
||||||
|
|
||||||
The exact wire format is not fully pinned in the OpenAPI spec; we use a
|
The exact wire format is not fully pinned in the OpenAPI spec; we use
|
||||||
convention that the client `_normalize_value` helper builds:
|
the client's friendly value convention (see ``TEIClient.get_values``):
|
||||||
|
|
||||||
- annual fields: {field_key, year_values: {"1": ..., "2": ...},
|
- annual fields: {field_key, year_values: {"1": ..., "2": ...},
|
||||||
risk_adjustment, notes}
|
risk_adjustment, notes}
|
||||||
|
|||||||
692
docs/Athena_TEI.md
Normal file
692
docs/Athena_TEI.md
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
# TEI Tool
|
||||||
|
|
||||||
|
The **TEI (Total Economic Impact) Tool** provides a configurable financial calculator for building client-specific business cases. It attaches to an existing Proposal or Engagement, inheriting client context (company name, industry, etc.) without redundant data entry, and exposes a REST API consumed by Streamlit calculators and LLM report-generation pipelines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Two parent objects define a TEI calculation:
|
||||||
|
|
||||||
|
- **TEIReport** (admin-configured template) — defines the field schema, analysis period, and discount rate. Created once per study type (e.g., "Amazon Connect 2026"). Multiple tools share one report.
|
||||||
|
- **TEITool** (one per client opportunity) — holds the actual values, financial summary, and version history. Inherits `name`, `description`, `owner`, `subscriber`, `proposal`, `engagement`, and `is_active` from {py:class}`core.models.BaseTool`.
|
||||||
|
|
||||||
|
The tool lifecycle is: **create → seed values → edit values → calculate → save version → export**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
| Model | Role |
|
||||||
|
|-------|------|
|
||||||
|
| `TEIReport` | Admin template: field schema + financial parameters |
|
||||||
|
| `TEIReportField` | One field definition (benefit or cost) on a report |
|
||||||
|
| `TEITool` | A specific calculation attached to a Proposal/Engagement |
|
||||||
|
| `TEIFieldValue` | Current value for one field, one year (or null for non-annual) |
|
||||||
|
| `TEIFinancialSummary` | Fixed-schema rollup (1-to-1 with a tool); written by `/calculate/` |
|
||||||
|
| `TEIVersion` | Immutable JSON snapshot of values + summary at a point in time |
|
||||||
|
|
||||||
|
**Public identifiers:** `TEIReport`, `TEITool`, and `TEIVersion` are addressed by a 12-character short UUID (`public_id`) in all API URLs. `TEIReportField` is addressed by its integer PK under its parent report's `public_id`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Base URL and Authentication
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/v1/tei/
|
||||||
|
```
|
||||||
|
|
||||||
|
All endpoints require `IsAuthenticated`. The error envelope is always:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "ERROR_CODE",
|
||||||
|
"message": "Human-readable description",
|
||||||
|
"details": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
| Operation | Admin | Consultant | Viewer |
|
||||||
|
|-----------|:-----:|:----------:|:------:|
|
||||||
|
| Create/edit/delete Reports and Fields | Yes | No | No |
|
||||||
|
| Create TEITool | Yes | Yes | No |
|
||||||
|
| Edit values, trigger calculation, save version | Yes | Yes | No |
|
||||||
|
| View tools, values, summary, versions, export | Yes | Yes | Yes |
|
||||||
|
| Delete TEITool | Yes | Yes (own only) | No |
|
||||||
|
|
||||||
|
Data isolation follows the parent Proposal/Engagement — if a user cannot see the Proposal, they cannot see the attached TEI tool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reports (admin)
|
||||||
|
|
||||||
|
Reports are read-only for consultants; admins manage them.
|
||||||
|
|
||||||
|
### List / Create
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tei/reports/
|
||||||
|
POST /api/v1/tei/reports/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query params (GET):** `status` (`draft` | `active` | `archived`), `vendor`
|
||||||
|
|
||||||
|
**POST body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Amazon Connect 2026",
|
||||||
|
"vendor": "AWS",
|
||||||
|
"version": "1.0",
|
||||||
|
"description": "Based on Forrester TEI February 2026",
|
||||||
|
"analysis_period_years": 3,
|
||||||
|
"discount_rate": "0.10",
|
||||||
|
"status": "draft"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response shape (list item):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "Ab3Cd5Ef7Gh9",
|
||||||
|
"name": "Amazon Connect 2026",
|
||||||
|
"vendor": "AWS",
|
||||||
|
"version": "1.0",
|
||||||
|
"description": "...",
|
||||||
|
"analysis_period_years": 3,
|
||||||
|
"discount_rate": "0.1000",
|
||||||
|
"status": "active",
|
||||||
|
"field_count": 12,
|
||||||
|
"instance_count": 3,
|
||||||
|
"created_at": "2025-01-15T10:00:00Z",
|
||||||
|
"updated_at": "2025-01-15T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detail / Update / Delete
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tei/reports/{report_id}/
|
||||||
|
PUT /api/v1/tei/reports/{report_id}/
|
||||||
|
PATCH /api/v1/tei/reports/{report_id}/
|
||||||
|
DELETE /api/v1/tei/reports/{report_id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Business rules:**
|
||||||
|
- `DELETE` is only allowed when `status = draft` and no tools exist (`instance_count = 0`).
|
||||||
|
- `analysis_period_years` cannot be changed if any tools reference this report — raises `409 MODEL_HAS_INSTANCES`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Report Fields (admin)
|
||||||
|
|
||||||
|
Fields define what the calculator collects. They are nested under their parent report.
|
||||||
|
|
||||||
|
### List / Add
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tei/reports/{report_id}/fields/
|
||||||
|
POST /api/v1/tei/reports/{report_id}/fields/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query params (GET):** `table` (`benefits` | `costs`), `category`
|
||||||
|
|
||||||
|
**POST body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"table": "benefits",
|
||||||
|
"field_key": "ai_resolution_efficiency",
|
||||||
|
"label": "AI-Driven Contact Resolution Efficiency",
|
||||||
|
"description": "Labor savings from AI-powered self-service",
|
||||||
|
"field_type": "currency",
|
||||||
|
"category": "AI Resolution",
|
||||||
|
"default_value": null,
|
||||||
|
"is_annual": true,
|
||||||
|
"risk_adjustment": "0.20",
|
||||||
|
"sort_order": 1,
|
||||||
|
"is_required": true,
|
||||||
|
"source_notes": "Forrester TEI 2026 — $64.3M over 3 years"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`field_type` choices: `currency`, `percentage`, `integer`, `decimal`, `text`
|
||||||
|
|
||||||
|
Adding a field to a report that already has tools will back-fill `TEIFieldValue` rows (null values) for every existing tool.
|
||||||
|
|
||||||
|
### Update / Delete
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/v1/tei/reports/{report_id}/fields/{field_id}/
|
||||||
|
PATCH /api/v1/tei/reports/{report_id}/fields/{field_id}/
|
||||||
|
DELETE /api/v1/tei/reports/{report_id}/fields/{field_id}/?confirm=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Protected fields** — once any tool has values for a field, these attributes are immutable (raises `409 PROTECTED_FIELD`):
|
||||||
|
- `field_key`
|
||||||
|
- `field_type`
|
||||||
|
- `is_annual`
|
||||||
|
|
||||||
|
Always mutable: `label`, `description`, `default_value`, `risk_adjustment`, `sort_order`, `category`, `source_notes`.
|
||||||
|
|
||||||
|
`DELETE` requires `?confirm=true` and cascades all `TEIFieldValue` rows for that field across every tool. Historical version snapshots are unaffected (they are stored as JSON).
|
||||||
|
|
||||||
|
### Reorder
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/v1/tei/reports/{report_id}/fields/reorder/
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field_order": [
|
||||||
|
{"id": 1, "sort_order": 1},
|
||||||
|
{"id": 2, "sort_order": 2}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
A tool is one TEI calculation attached to one Proposal or Engagement.
|
||||||
|
|
||||||
|
### List / Create
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tei/tools/
|
||||||
|
POST /api/v1/tei/tools/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query params (GET):** `status`, `report` (report `public_id`), `proposal` (PK), `engagement` (PK)
|
||||||
|
|
||||||
|
**POST body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"report": "Ab3Cd5Ef7Gh9",
|
||||||
|
"proposal": 42,
|
||||||
|
"name": null,
|
||||||
|
"status": "draft"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supply exactly one of `proposal` or `engagement`. `name` defaults to the report name when omitted.
|
||||||
|
|
||||||
|
**Side effects on create:**
|
||||||
|
- Creates `TEIFieldValue` rows for every field in the report (populated from `default_value`, or empty string).
|
||||||
|
- For annual fields, creates one row per year (1 through `analysis_period_years`).
|
||||||
|
- Creates an empty `TEIFinancialSummary` record.
|
||||||
|
|
||||||
|
A duplicate active tool for the same report + proposal/engagement raises `409 DUPLICATE_INSTANCE`.
|
||||||
|
|
||||||
|
### Detail / Update / Delete
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tei/tools/{tool_id}/
|
||||||
|
PUT /api/v1/tei/tools/{tool_id}/
|
||||||
|
PATCH /api/v1/tei/tools/{tool_id}/
|
||||||
|
DELETE /api/v1/tei/tools/{tool_id}/?confirm=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `name` and `status` are mutable via `PUT`/`PATCH`. `DELETE` requires `?confirm=true` and cascades all values, versions, and summary.
|
||||||
|
|
||||||
|
**Tool detail response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "Xy1Za2Bc3De4",
|
||||||
|
"report": {
|
||||||
|
"id": "Ab3Cd5Ef7Gh9",
|
||||||
|
"name": "Amazon Connect 2026",
|
||||||
|
"vendor": "AWS",
|
||||||
|
"version": "1.0",
|
||||||
|
"analysis_period_years": 3,
|
||||||
|
"discount_rate": "0.1000"
|
||||||
|
},
|
||||||
|
"opportunity": {
|
||||||
|
"id": "...",
|
||||||
|
"name": "Acme Corp CX Transformation",
|
||||||
|
"proposal_id": "...",
|
||||||
|
"client": {
|
||||||
|
"id": "...",
|
||||||
|
"name": "Acme Corporation",
|
||||||
|
"short_name": "Acme"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"engagement": null,
|
||||||
|
"name": "Amazon Connect 2026",
|
||||||
|
"status": "in_progress",
|
||||||
|
"current_version": 2,
|
||||||
|
"summary": {
|
||||||
|
"net_present_value": "14200000.00",
|
||||||
|
"roi_percentage": "289.8000",
|
||||||
|
"total_benefits_pv": "19100000.00",
|
||||||
|
"total_costs_pv": "4900000.00"
|
||||||
|
},
|
||||||
|
"created_date": "2025-01-20T14:30:00Z",
|
||||||
|
"modified_date": "2025-02-03T09:15:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Values
|
||||||
|
|
||||||
|
### Get all values
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tei/tools/{tool_id}/values/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query params:** `table` (`benefits` | `costs`), `category`
|
||||||
|
|
||||||
|
Annual fields return a `years` object; non-annual fields return a flat `value`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool_id": "Xy1Za2Bc3De4",
|
||||||
|
"report": "Amazon Connect 2026",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"field_key": "ai_resolution_efficiency",
|
||||||
|
"label": "AI-Driven Contact Resolution Efficiency",
|
||||||
|
"table": "benefits",
|
||||||
|
"category": "AI Resolution",
|
||||||
|
"field_type": "currency",
|
||||||
|
"is_annual": true,
|
||||||
|
"risk_adjustment": "0.20",
|
||||||
|
"years": {
|
||||||
|
"1": {"value": "12500000.00", "risk_adjustment": null, "notes": ""},
|
||||||
|
"2": {"value": "24800000.00", "risk_adjustment": null, "notes": ""},
|
||||||
|
"3": {"value": "27000000.00", "risk_adjustment": "0.25", "notes": "Phase 3 risk"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"field_key": "legacy_termination",
|
||||||
|
"label": "Legacy Solution Termination Fees",
|
||||||
|
"table": "costs",
|
||||||
|
"category": "Migration",
|
||||||
|
"field_type": "currency",
|
||||||
|
"is_annual": false,
|
||||||
|
"risk_adjustment": null,
|
||||||
|
"value": "1200000.00",
|
||||||
|
"notes": "Confirmed by procurement"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bulk update
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/v1/tei/tools/{tool_id}/values/
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"values": [
|
||||||
|
{"field_key": "ai_resolution_efficiency", "year": 1, "value": "12500000.00", "risk_adjustment": null, "notes": null},
|
||||||
|
{"field_key": "ai_resolution_efficiency", "year": 2, "value": "24800000.00", "risk_adjustment": null, "notes": "60% containment by month 18"},
|
||||||
|
{"field_key": "legacy_termination", "year": null, "value": "1200000.00", "risk_adjustment": null, "notes": null}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `year` must be `null` for non-annual fields, `1..N` for annual fields.
|
||||||
|
- Validation raises `400 INVALID_FIELD_KEY`, `400 INVALID_YEAR`, or `400 TYPE_MISMATCH` on bad input.
|
||||||
|
- Does **not** auto-recalculate — call `/calculate/` explicitly.
|
||||||
|
- Returns the same shape as `GET /values/`.
|
||||||
|
|
||||||
|
### Update single value
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/v1/tei/tools/{tool_id}/values/{field_key}/?year=1
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"value": "13000000.00",
|
||||||
|
"risk_adjustment": "0.15",
|
||||||
|
"notes": "Revised after benchmarking call"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`?year` is required for annual fields; omit (or pass `year=null`) for non-annual fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Calculation
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/tei/tools/{tool_id}/calculate/
|
||||||
|
```
|
||||||
|
|
||||||
|
No request body. Uses current stored values. Persists result in `TEIFinancialSummary` (upsert).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_benefits_pv": "19100000.00",
|
||||||
|
"total_costs_pv": "4900000.00",
|
||||||
|
"net_present_value": "14200000.00",
|
||||||
|
"roi_percentage": "289.8000",
|
||||||
|
"payback_period_months": 8,
|
||||||
|
"total_benefits_nominal": "22300000.00",
|
||||||
|
"total_costs_nominal": "5400000.00",
|
||||||
|
"benefits_year_1": "5200000.00",
|
||||||
|
"benefits_year_2": "9800000.00",
|
||||||
|
"benefits_year_3": "7300000.00",
|
||||||
|
"costs_year_1": "3800000.00",
|
||||||
|
"costs_year_2": "900000.00",
|
||||||
|
"costs_year_3": "700000.00",
|
||||||
|
"discount_rate": "0.1000",
|
||||||
|
"calculated_at": "2025-02-03T09:15:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tei/tools/{tool_id}/summary/
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the stored `TEIFinancialSummary` in the same shape as the `/calculate/` response. Returns `404 SUMMARY_NOT_CALCULATED` if `/calculate/` has never been called.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versions
|
||||||
|
|
||||||
|
Versions are **immutable** snapshots. They cannot be updated or deleted via the API.
|
||||||
|
|
||||||
|
### List
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tei/tools/{tool_id}/versions/
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns headline summary only (NPV + ROI) per version item.
|
||||||
|
|
||||||
|
### Save new version
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/tei/tools/{tool_id}/versions/
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2025-02-03",
|
||||||
|
"note": "Updated with actuals from finance team. Containment revised to 24%."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Side effects:**
|
||||||
|
1. Auto-triggers `/calculate/` to ensure the summary is current.
|
||||||
|
2. Snapshots all current `TEIFieldValue` rows as JSON into `values_snapshot`.
|
||||||
|
3. Snapshots current `TEIFinancialSummary` as JSON into `summary_snapshot`.
|
||||||
|
4. Increments `tool.current_version`.
|
||||||
|
|
||||||
|
Returns the created version (without full snapshots — use the detail endpoint to retrieve those).
|
||||||
|
|
||||||
|
### Version detail
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tei/tools/{tool_id}/versions/{version_number}/
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns full `values_snapshot` and `summary_snapshot`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Export (LLM payload)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tei/tools/{tool_id}/export/
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns everything needed for LLM report generation in one payload. Auto-recalculates before building the response.
|
||||||
|
|
||||||
|
**Response shape:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"export_date": "2025-02-03T09:30:00Z",
|
||||||
|
"client": {
|
||||||
|
"name": "Acme Corporation",
|
||||||
|
"short_name": "Acme",
|
||||||
|
"industry": "Financial Services",
|
||||||
|
"size": "enterprise"
|
||||||
|
},
|
||||||
|
"opportunity": {
|
||||||
|
"name": "CX Transformation",
|
||||||
|
"stage": "proposal"
|
||||||
|
},
|
||||||
|
"engagement": null,
|
||||||
|
"report": {
|
||||||
|
"name": "Amazon Connect 2026",
|
||||||
|
"vendor": "AWS",
|
||||||
|
"version": "1.0",
|
||||||
|
"analysis_period_years": 3,
|
||||||
|
"discount_rate": "0.10"
|
||||||
|
},
|
||||||
|
"benefits": [
|
||||||
|
{
|
||||||
|
"field_key": "ai_resolution_efficiency",
|
||||||
|
"label": "AI-Driven Contact Resolution Efficiency",
|
||||||
|
"category": "AI Resolution",
|
||||||
|
"risk_adjustment": "0.20",
|
||||||
|
"source_notes": "Forrester TEI 2026 — $64.3M over 3 years",
|
||||||
|
"years": {
|
||||||
|
"1": {"nominal": "12500000.00", "risk_adjusted": "10000000.00"},
|
||||||
|
"2": {"nominal": "24800000.00", "risk_adjusted": "19840000.00"},
|
||||||
|
"3": {"nominal": "27000000.00", "risk_adjusted": "20250000.00"}
|
||||||
|
},
|
||||||
|
"total_nominal": "64300000.00",
|
||||||
|
"total_risk_adjusted": "50090000.00",
|
||||||
|
"present_value": "41200000.00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"costs": [
|
||||||
|
{
|
||||||
|
"field_key": "connect_licensing",
|
||||||
|
"label": "Amazon Connect Licensing & Usage",
|
||||||
|
"category": "Platform",
|
||||||
|
"source_notes": "Per-minute pricing model",
|
||||||
|
"years": {
|
||||||
|
"1": {"value": "2000000.00"},
|
||||||
|
"2": {"value": "2200000.00"},
|
||||||
|
"3": {"value": "2400000.00"}
|
||||||
|
},
|
||||||
|
"total_nominal": "6600000.00",
|
||||||
|
"present_value": "5490000.00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"total_benefits_pv": "19100000.00",
|
||||||
|
"total_costs_pv": "4900000.00",
|
||||||
|
"net_present_value": "14200000.00",
|
||||||
|
"roi_percentage": "289.80",
|
||||||
|
"payback_period_months": 8
|
||||||
|
},
|
||||||
|
"versions": [
|
||||||
|
{"version_number": 2, "date": "2025-02-03", "note": "Actuals from finance team"},
|
||||||
|
{"version_number": 1, "date": "2025-01-15", "note": "Initial Forrester defaults"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-annual cost fields appear under `"years": {"1": {"value": "..."}}` (treated as Year 1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Tool Rollup
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tei/summary/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query params:** `status`, `vendor`, `min_npv`
|
||||||
|
|
||||||
|
Returns aggregate NPV across all calculated tools plus per-tool headline rows. Only includes tools where `/calculate/` has been run at least once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Jupyter Notebook Workflow
|
||||||
|
|
||||||
|
A typical notebook session using the TEI API:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE = "https://athena.example.com/api/v1/tei"
|
||||||
|
HEADERS = {"Authorization": "Token <your-token>"}
|
||||||
|
TOOL_ID = "Xy1Za2Bc3De4" # TEITool public_id
|
||||||
|
|
||||||
|
# 1. Load tool metadata and client context
|
||||||
|
tool = requests.get(f"{BASE}/tools/{TOOL_ID}/", headers=HEADERS).json()
|
||||||
|
|
||||||
|
# 2. Load current values (benefits + costs)
|
||||||
|
values_resp = requests.get(f"{BASE}/tools/{TOOL_ID}/values/", headers=HEADERS).json()
|
||||||
|
values = values_resp["values"]
|
||||||
|
|
||||||
|
# 3. Update values (customize for the client)
|
||||||
|
updated_rows = [
|
||||||
|
{"field_key": "ai_resolution_efficiency", "year": 1, "value": "11000000.00"},
|
||||||
|
{"field_key": "ai_resolution_efficiency", "year": 2, "value": "23000000.00"},
|
||||||
|
{"field_key": "ai_resolution_efficiency", "year": 3, "value": "25500000.00"},
|
||||||
|
{"field_key": "legacy_termination", "year": None, "value": "950000.00"},
|
||||||
|
]
|
||||||
|
requests.put(
|
||||||
|
f"{BASE}/tools/{TOOL_ID}/values/",
|
||||||
|
headers=HEADERS,
|
||||||
|
json={"values": updated_rows},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Recalculate
|
||||||
|
summary = requests.post(f"{BASE}/tools/{TOOL_ID}/calculate/", headers=HEADERS).json()
|
||||||
|
print(f"NPV: {summary['net_present_value']}, ROI: {summary['roi_percentage']}%")
|
||||||
|
|
||||||
|
# 5. Save a version snapshot
|
||||||
|
requests.post(
|
||||||
|
f"{BASE}/tools/{TOOL_ID}/versions/",
|
||||||
|
headers=HEADERS,
|
||||||
|
json={"date": "2025-02-03", "note": "Notebook scenario — conservative containment"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Get the full LLM-ready export
|
||||||
|
export = requests.get(f"{BASE}/tools/{TOOL_ID}/export/", headers=HEADERS).json()
|
||||||
|
# export["benefits"], export["costs"], export["summary"] are all populated
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finding the tool_id
|
||||||
|
|
||||||
|
If you know the Proposal PK or Engagement PK:
|
||||||
|
|
||||||
|
```python
|
||||||
|
tools = requests.get(
|
||||||
|
f"{BASE}/tools/",
|
||||||
|
headers=HEADERS,
|
||||||
|
params={"proposal": 42, "status": "in_progress"},
|
||||||
|
).json()
|
||||||
|
tool_id = tools["results"][0]["id"]
|
||||||
|
```
|
||||||
|
|
||||||
|
If the tool doesn't exist yet, create it first:
|
||||||
|
|
||||||
|
```python
|
||||||
|
new_tool = requests.post(
|
||||||
|
f"{BASE}/tools/",
|
||||||
|
headers=HEADERS,
|
||||||
|
json={"report": "Ab3Cd5Ef7Gh9", "proposal": 42},
|
||||||
|
).json()
|
||||||
|
tool_id = new_tool["id"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Calculation Logic Reference
|
||||||
|
|
||||||
|
### Benefit risk adjustment
|
||||||
|
|
||||||
|
```
|
||||||
|
risk_adjusted_value = nominal_value × (1 − risk_adjustment)
|
||||||
|
```
|
||||||
|
|
||||||
|
`risk_adjustment` comes from the `TEIFieldValue` instance override if set, otherwise from `TEIReportField.risk_adjustment`, otherwise 0. Costs are **never** risk-adjusted.
|
||||||
|
|
||||||
|
### Present value discounting
|
||||||
|
|
||||||
|
Each annual value is discounted to today:
|
||||||
|
|
||||||
|
```
|
||||||
|
PV = value / (1 + discount_rate) ^ year
|
||||||
|
```
|
||||||
|
|
||||||
|
where `year` is 1, 2, … N and `discount_rate` comes from the `TEIReport`. Non-annual (one-time) values are treated as Year 1.
|
||||||
|
|
||||||
|
### Summary calculations
|
||||||
|
|
||||||
|
```
|
||||||
|
total_benefits_nominal = sum of all risk-adjusted benefit values (all years)
|
||||||
|
total_costs_nominal = sum of all cost values (all years)
|
||||||
|
|
||||||
|
total_benefits_pv = Σ PV(risk_adjusted_benefit, year) for all benefit fields
|
||||||
|
total_costs_pv = Σ PV(cost, year) for all cost fields
|
||||||
|
|
||||||
|
net_present_value = total_benefits_pv − total_costs_pv
|
||||||
|
roi_percentage = (net_present_value / total_costs_pv) × 100
|
||||||
|
```
|
||||||
|
|
||||||
|
`roi_percentage` is `null` when `total_costs_pv` is 0.
|
||||||
|
|
||||||
|
### Payback period
|
||||||
|
|
||||||
|
Annual values are prorated evenly across 12 months. One-time (non-annual) values land in month 1 as a lump sum. The payback month is the first month where cumulative risk-adjusted benefits ≥ cumulative costs. Returns `null` if never achieved within `analysis_period_years`.
|
||||||
|
|
||||||
|
### Edge cases
|
||||||
|
|
||||||
|
| Scenario | Behaviour |
|
||||||
|
|----------|-----------|
|
||||||
|
| Null/blank field value | Treated as 0 |
|
||||||
|
| All benefits zero | NPV = −total_costs_pv, ROI = null if costs are also 0 |
|
||||||
|
| All costs zero | NPV = total_benefits_pv, ROI = null (division by zero) |
|
||||||
|
| `risk_adjustment = 1.0` | Benefit is zeroed out (fully excluded) |
|
||||||
|
| `risk_adjustment = 0.0` | Full nominal value used |
|
||||||
|
| Non-annual field | Folded into Year 1 for NPV and payback |
|
||||||
|
|
||||||
|
All arithmetic uses Python `Decimal` to avoid floating-point drift. Values are stored as strings and cast at calculation time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
| HTTP | Code | When |
|
||||||
|
|------|------|------|
|
||||||
|
| 400 | `VALIDATION_ERROR` | Missing required field, wrong type |
|
||||||
|
| 400 | `INVALID_FIELD_KEY` | `field_key` not defined in the tool's report |
|
||||||
|
| 400 | `INVALID_YEAR` | Year missing for annual field, or present for non-annual, or out of range |
|
||||||
|
| 400 | `TYPE_MISMATCH` | Value cannot be parsed as the field's `field_type` |
|
||||||
|
| 401 | `AUTHENTICATION_REQUIRED` | Missing or invalid token |
|
||||||
|
| 403 | `PERMISSION_DENIED` | Role does not allow this operation |
|
||||||
|
| 404 | `NOT_FOUND` | Resource does not exist |
|
||||||
|
| 404 | `SUMMARY_NOT_CALCULATED` | `GET /summary/` before any `/calculate/` run |
|
||||||
|
| 409 | `DUPLICATE_INSTANCE` | Creating a second active tool for the same report + proposal/engagement |
|
||||||
|
| 409 | `FIELD_KEY_EXISTS` | `field_key` already exists within the report |
|
||||||
|
| 409 | `PROTECTED_FIELD` | Changing `field_key`, `field_type`, or `is_annual` when values exist |
|
||||||
|
| 409 | `MODEL_HAS_INSTANCES` | Deleting a report or changing `analysis_period_years` when tools exist |
|
||||||
|
| 422 | `CALCULATION_ERROR` | Calculation failed unexpectedly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*TEI Tool — Athena*
|
||||||
BIN
studies/.DS_Store
vendored
Normal file
BIN
studies/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
studies/202512_GenesysCX/.DS_Store
vendored
Normal file
BIN
studies/202512_GenesysCX/.DS_Store
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
studies/202602_AmazonConnect/.DS_Store
vendored
Normal file
BIN
studies/202602_AmazonConnect/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -31,9 +31,14 @@ DISCOUNT_RATE = 0.10
|
|||||||
#: Analysis horizon (years).
|
#: Analysis horizon (years).
|
||||||
ANALYSIS_YEARS = 3
|
ANALYSIS_YEARS = 3
|
||||||
|
|
||||||
#: Optional Athena Proposal ID this tool is linked to (when known).
|
def _int_env(name: str) -> int | None:
|
||||||
PROPOSAL_ID: int | None = (
|
raw = os.getenv(name, "").strip()
|
||||||
int(os.environ["PALLADIUM_PROPOSAL_ID"])
|
return int(raw) if raw else None
|
||||||
if os.getenv("PALLADIUM_PROPOSAL_ID")
|
|
||||||
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")
|
||||||
|
|||||||
604
studies/202602_AmazonConnect/notebooks/00_provision.ipynb
Normal file
604
studies/202602_AmazonConnect/notebooks/00_provision.ipynb
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "2b0c5d04",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# 00 · Provision — Amazon Connect TEI in Athena\n",
|
||||||
|
"\n",
|
||||||
|
"Creates everything this study needs in the Athena sandbox, end to end:\n",
|
||||||
|
"\n",
|
||||||
|
"1. **Report template** *Amazon Connect 2026* (3 years, 10% discount rate) + **field definitions**\n",
|
||||||
|
"2. **Client selection** — browse the CRM, pick the client, and pull their profile (industry, agent counts, revenue) so nothing is re-entered\n",
|
||||||
|
"3. **Attachment** — pick (or create) the **Proposal or Engagement** the tool binds to\n",
|
||||||
|
"4. **Tool instance** + **seed values** from `seed_data.py` (the published Forrester figures)\n",
|
||||||
|
"5. **Server-side calculation** and **verification** against the published totals: **NPV \\$78.7M · ROI 342% · payback <6 months**\n",
|
||||||
|
"6. Persists all IDs to `.env` so the other notebooks, the CLI, and the Streamlit app pick them up automatically.\n",
|
||||||
|
"\n",
|
||||||
|
"Safe to re-run — every step finds existing objects before creating new ones."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 5,
|
||||||
|
"id": "5bcc7740",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"✅ Athena connected — https://athena.ouranos.helu.ca (1 report templates visible)\n",
|
||||||
|
"📁 Study: 202602_AmazonConnect\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=\"202602_AmazonConnect\")\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": "cc8a4e03",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 1 · Report template (find or create)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 6,
|
||||||
|
"id": "386ae38b",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Found existing report template xsUTbjh4iDnJ (status: active)\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"REPORT_NAME, VENDOR = \"Amazon Connect 2026\", \"AWS\"\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=\"Forrester Total Economic Impact of Amazon Connect, Feb 2026\",\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": "dab83777",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 2 · Field definitions\n",
|
||||||
|
"\n",
|
||||||
|
"Derived straight from `seed_data.py`. Three methodology notes:\n",
|
||||||
|
"\n",
|
||||||
|
"- **Benefit risk adjustment** lives on the field definition — Athena applies `value × (1 − risk_adj)` at calculate time.\n",
|
||||||
|
"- **Costs**: Athena never risk-adjusts costs, but Forrester adjusts them *upward*. We therefore push cost values pre-multiplied by `(1 + risk_adj)` in step 5, and keep the field-level adjustment at 0 so nothing is applied twice.\n",
|
||||||
|
"- **Year-0 \"Initial\" amounts** have no native slot in the TEI API, so each cost gets a companion non-annual `<key>_initial` field. The client folds these back automatically on read."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 7,
|
||||||
|
"id": "dc46ab46",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"0 fields created, 11 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\", # applied client-side, see note above\n",
|
||||||
|
" \"sort_order\": sort,\n",
|
||||||
|
" \"is_required\": True,\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": "d5841b4e",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 3 · Select the client\n",
|
||||||
|
"\n",
|
||||||
|
"Browse the CRM. Adjust `CLIENT_SEARCH` to narrow the list, then set `CLIENT_ID`\n",
|
||||||
|
"below (it auto-selects when exactly one client matches)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 12,
|
||||||
|
"id": "4070b9c2",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"ename": "AttributeError",
|
||||||
|
"evalue": "'TEIClient' object has no attribute 'list_clients'",
|
||||||
|
"output_type": "error",
|
||||||
|
"traceback": [
|
||||||
|
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
|
||||||
|
"\u001b[31mAttributeError\u001b[39m Traceback (most recent call last)",
|
||||||
|
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[12]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m CLIENT_SEARCH = \u001b[33m\"Global\"\u001b[39m \u001b[38;5;66;03m# e.g. \"Acme\" — empty lists everyone\u001b[39;00m\n\u001b[32m 2\u001b[39m \n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m clients = client.list_clients(search=CLIENT_SEARCH \u001b[38;5;28;01mor\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m)\n\u001b[32m 4\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m clients:\n\u001b[32m 5\u001b[39m display(pd.DataFrame(clients)[\n\u001b[32m 6\u001b[39m [c for c in (\"id\", \"name\", \"vertical\", \"client_type\", \"employee_count\",\n",
|
||||||
|
"\u001b[31mAttributeError\u001b[39m: 'TEIClient' object has no attribute 'list_clients'"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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": 10,
|
||||||
|
"id": "4e97978c",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"ename": "NameError",
|
||||||
|
"evalue": "name 'clients' is not defined",
|
||||||
|
"output_type": "error",
|
||||||
|
"traceback": [
|
||||||
|
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
|
||||||
|
"\u001b[31mNameError\u001b[39m Traceback (most recent call last)",
|
||||||
|
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[10]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m CLIENT_ID = \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;66;03m# ← set from the `id` column above, or leave for auto-pick\u001b[39;00m\n\u001b[32m 2\u001b[39m \n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m CLIENT_ID \u001b[38;5;28;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;28;01mand\u001b[39;00m len(clients) == \u001b[32m1\u001b[39m:\n\u001b[32m 4\u001b[39m CLIENT_ID = clients[\u001b[32m0\u001b[39m][\u001b[33m\"id\"\u001b[39m]\n\u001b[32m 5\u001b[39m print(f\"Auto-selected the only client: {clients[\u001b[32m0\u001b[39m][\u001b[33m'name'\u001b[39m]} (id={CLIENT_ID})\")\n\u001b[32m 6\u001b[39m \u001b[38;5;28;01massert\u001b[39;00m CLIENT_ID \u001b[38;5;28;01mis\u001b[39;00m \u001b[38;5;28;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m, \u001b[33m\"Set CLIENT_ID from the table above and re-run this cell.\"\u001b[39m\n",
|
||||||
|
"\u001b[31mNameError\u001b[39m: name 'clients' is not defined"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"CLIENT_ID = None # ← 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",
|
||||||
|
"print(f\"\\nClient profile — no re-entry needed downstream:\")\n",
|
||||||
|
"display(pd.DataFrame([profile]).T.rename(columns={0: CLIENT_NAME}))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "0ae76599",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### Client data → study assumptions\n",
|
||||||
|
"\n",
|
||||||
|
"Where the CRM has real numbers, they override the Forrester composite\n",
|
||||||
|
"(2,000 agents / 200 supervisors). `CLIENT_ASSUMPTIONS` is what `01_benefits.ipynb`\n",
|
||||||
|
"uses for scaling discussions."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 11,
|
||||||
|
"id": "fcccc591",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"ename": "NameError",
|
||||||
|
"evalue": "name 'profile' is not defined",
|
||||||
|
"output_type": "error",
|
||||||
|
"traceback": [
|
||||||
|
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
|
||||||
|
"\u001b[31mNameError\u001b[39m Traceback (most recent call last)",
|
||||||
|
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[11]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m CLIENT_ASSUMPTIONS = dict(seed.ASSUMPTIONS)\n\u001b[32m 2\u001b[39m overrides = {\n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[33m\"agents_fte\"\u001b[39m: profile.get(\u001b[33m\"contact_center_agent_count\"\u001b[39m),\n\u001b[32m 4\u001b[39m \u001b[33m\"supervisors_fte\"\u001b[39m: profile.get(\u001b[33m\"supervisor_count\"\u001b[39m),\n\u001b[32m 5\u001b[39m }\n\u001b[32m 6\u001b[39m rows = []\n",
|
||||||
|
"\u001b[31mNameError\u001b[39m: name 'profile' is not defined"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"CLIENT_ASSUMPTIONS = dict(seed.ASSUMPTIONS)\n",
|
||||||
|
"overrides = {\n",
|
||||||
|
" \"agents_fte\": profile.get(\"contact_center_agent_count\"),\n",
|
||||||
|
" \"supervisors_fte\": profile.get(\"supervisor_count\"),\n",
|
||||||
|
"}\n",
|
||||||
|
"rows = []\n",
|
||||||
|
"for key, val in overrides.items():\n",
|
||||||
|
" if val:\n",
|
||||||
|
" rows.append({\"assumption\": key, \"Forrester composite\": seed.ASSUMPTIONS[key],\n",
|
||||||
|
" f\"{CLIENT_NAME} (CRM)\": val})\n",
|
||||||
|
" CLIENT_ASSUMPTIONS[key] = val\n",
|
||||||
|
"\n",
|
||||||
|
"if rows:\n",
|
||||||
|
" display(pd.DataFrame(rows))\n",
|
||||||
|
" scale = CLIENT_ASSUMPTIONS[\"agents_fte\"] / seed.ASSUMPTIONS[\"agents_fte\"]\n",
|
||||||
|
" print(f\"Indicative scale factor vs composite: {scale:.2f}× \"\n",
|
||||||
|
" f\"(apply judgement — benefits don't all scale linearly)\")\n",
|
||||||
|
"else:\n",
|
||||||
|
" print(\"CRM has no agent/supervisor counts for this client — using the \"\n",
|
||||||
|
" \"Forrester composite organization as-is.\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "422c2ed6",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 4 · Pick the attachment — Proposal or Engagement\n",
|
||||||
|
"\n",
|
||||||
|
"A TEI tool must attach to exactly one of the two. Both lists below are\n",
|
||||||
|
"already filtered to the selected client."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "57dec6cf",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"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",
|
||||||
|
" \"due_date\": p.get(\"due_date\")}\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",
|
||||||
|
" \"start\": e.get(\"start_date\"), \"end\": e.get(\"end_date\")}\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": null,
|
||||||
|
"id": "19336bcc",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"# Set exactly ONE of these (ids from the tables above). Leave both None to\n",
|
||||||
|
"# auto-pick — single existing proposal/engagement wins; otherwise a sandbox\n",
|
||||||
|
"# opportunity + proposal is created for the client.\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 Transformation (sandbox)\",\n",
|
||||||
|
" client_id=CLIENT_ID,\n",
|
||||||
|
" description=\"Created by Palladium 00_provision for the Amazon Connect TEI.\",\n",
|
||||||
|
" )\n",
|
||||||
|
" prop = client.create_proposal(\n",
|
||||||
|
" name=f\"{CLIENT_NAME} — Amazon Connect 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": "0056bce9",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 5 · Tool instance (find or create) & seed the published values"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "017ae9db",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"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} — Amazon Connect 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": null,
|
||||||
|
"id": "20e2a736",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"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.\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "697794fd",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 6 · Calculate & verify against the published study"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "b7ac5d24",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"summary = client.calculate(TOOL_ID)\n",
|
||||||
|
"client.print_summary(TOOL_ID)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "13d84001",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"# Published Forrester totals (3-yr risk-adjusted PV @ 10%)\n",
|
||||||
|
"PUBLISHED = {\n",
|
||||||
|
" \"total_benefits_pv\": 101_696_791,\n",
|
||||||
|
" \"total_costs_pv\": 22_983_076,\n",
|
||||||
|
" \"net_present_value\": 78_713_715,\n",
|
||||||
|
" \"roi_percentage\": 342,\n",
|
||||||
|
"}\n",
|
||||||
|
"# Tolerance: Athena discounts Year-0 'initial' amounts as Year 1 (Forrester\n",
|
||||||
|
"# leaves Year 0 undiscounted) — expected drift is ~$0.1M on costs (≈0.15%).\n",
|
||||||
|
"TOLERANCE = 0.02\n",
|
||||||
|
"\n",
|
||||||
|
"rows, ok = [], True\n",
|
||||||
|
"for key, expected in PUBLISHED.items():\n",
|
||||||
|
" actual = float(summary.get(key) or 0)\n",
|
||||||
|
" diff = (actual - expected) / expected\n",
|
||||||
|
" rows.append({\"metric\": key, \"published\": f\"{expected:,.0f}\",\n",
|
||||||
|
" \"athena\": f\"{actual:,.0f}\", \"diff\": f\"{diff:+.2%}\"})\n",
|
||||||
|
" ok &= abs(diff) <= TOLERANCE\n",
|
||||||
|
"\n",
|
||||||
|
"display(pd.DataFrame(rows))\n",
|
||||||
|
"payback = summary.get(\"payback_period_months\")\n",
|
||||||
|
"print(f\"Payback: {payback} months (published: <6 months)\")\n",
|
||||||
|
"assert ok, f\"Server totals drifted more than {TOLERANCE:.0%} from the published study — investigate before proceeding.\"\n",
|
||||||
|
"print(\"✅ Verified — Athena reproduces the published Forrester totals.\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "b48b6131",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## 7 · Save a baseline version & persist IDs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "148bdb2a",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"if not client.list_versions(TOOL_ID):\n",
|
||||||
|
" client.save_version(TOOL_ID, note=\"Baseline — published Forrester TEI figures (Feb 2026), moderate scenario.\")\n",
|
||||||
|
" print(\"Saved version 1 (baseline).\")\n",
|
||||||
|
"\n",
|
||||||
|
"ids = {\n",
|
||||||
|
" \"PALLADIUM_REPORT_PUBLIC_ID\": REPORT_ID,\n",
|
||||||
|
" \"PALLADIUM_TOOL_PUBLIC_ID\": TOOL_ID,\n",
|
||||||
|
"}\n",
|
||||||
|
"if PROPOSAL_ID is not None:\n",
|
||||||
|
" ids[\"PALLADIUM_PROPOSAL_ID\"] = str(PROPOSAL_ID)\n",
|
||||||
|
"if ENGAGEMENT_ID is not None:\n",
|
||||||
|
" ids[\"PALLADIUM_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}\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "571b48c3",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Done\n",
|
||||||
|
"\n",
|
||||||
|
"The sandbox now has a live, calculated Amazon Connect TEI tool attached to\n",
|
||||||
|
"the selected client's proposal/engagement — with the client's CRM profile\n",
|
||||||
|
"(industry, agent counts, revenue) flowing into the tool automatically.\n",
|
||||||
|
"\n",
|
||||||
|
"- **Continue the analysis** → `01_benefits.ipynb` → `04_export.ipynb` (they pick up the IDs from `.env` via `config.py`)\n",
|
||||||
|
"- **Interactive editing** → `make app` / `streamlit run app/main.py` — the tool appears in the sidebar\n",
|
||||||
|
"- **CLI sanity check** → `python -m palladium summary $PALLADIUM_TOOL_PUBLIC_ID`"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
"name": "python",
|
"name": "python",
|
||||||
"nbconvert_exporter": "python",
|
"nbconvert_exporter": "python",
|
||||||
"pygments_lexer": "ipython3",
|
"pygments_lexer": "ipython3",
|
||||||
"version": "3.13.7"
|
"version": "3.12.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nbformat": 4,
|
"nbformat": 4,
|
||||||
|
|||||||
@@ -205,7 +205,7 @@
|
|||||||
"name": "python",
|
"name": "python",
|
||||||
"nbconvert_exporter": "python",
|
"nbconvert_exporter": "python",
|
||||||
"pygments_lexer": "ipython3",
|
"pygments_lexer": "ipython3",
|
||||||
"version": "3.13.7"
|
"version": "3.12.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nbformat": 4,
|
"nbformat": 4,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"cells": [
|
"cells": [
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
|
"id": "4173501f",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"# 03 — Business Case\n",
|
"# 03 — Business Case\n",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
|
"id": "3cc4b453",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
@@ -35,6 +37,7 @@
|
|||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
|
"id": "62c56628",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
|
"id": "11fdc7c3",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## Local summary (no Athena round-trip)\n",
|
"## Local summary (no Athena round-trip)\n",
|
||||||
@@ -58,6 +62,7 @@
|
|||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
|
"id": "7d295b06",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
@@ -75,6 +80,7 @@
|
|||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
|
"id": "c3fc75fb",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
@@ -84,6 +90,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
|
"id": "dd58e4c8",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## Cash flow chart\n",
|
"## Cash flow chart\n",
|
||||||
@@ -95,6 +102,7 @@
|
|||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
|
"id": "5d293439",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
@@ -106,6 +114,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
|
"id": "3b4b7b39",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## Waterfall: Benefits PV → Costs PV → NPV"
|
"## Waterfall: Benefits PV → Costs PV → NPV"
|
||||||
@@ -114,6 +123,7 @@
|
|||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
|
"id": "04012e0f",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
@@ -126,6 +136,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
|
"id": "b48db610",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## Scenario analysis\n",
|
"## Scenario analysis\n",
|
||||||
@@ -140,6 +151,7 @@
|
|||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
|
"id": "1fb9aa20",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
@@ -171,6 +183,7 @@
|
|||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
|
"id": "0ff81b9d",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
@@ -179,6 +192,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
|
"id": "270745bf",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## Cross-check vs Athena (optional)\n",
|
"## Cross-check vs Athena (optional)\n",
|
||||||
@@ -190,6 +204,7 @@
|
|||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
|
"id": "c8239dbd",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
@@ -206,6 +221,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
|
"id": "794848f5",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"Continue with [`04_export.ipynb`](04_export.ipynb) →"
|
"Continue with [`04_export.ipynb`](04_export.ipynb) →"
|
||||||
@@ -213,8 +229,23 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
|
"kernelspec": {
|
||||||
"language_info": {"name": "python", "version": "3.11"}
|
"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": 4,
|
||||||
"nbformat_minor": 5
|
"nbformat_minor": 5
|
||||||
|
|||||||
@@ -187,7 +187,7 @@
|
|||||||
"name": "python",
|
"name": "python",
|
||||||
"nbconvert_exporter": "python",
|
"nbconvert_exporter": "python",
|
||||||
"pygments_lexer": "ipython3",
|
"pygments_lexer": "ipython3",
|
||||||
"version": "3.13.7"
|
"version": "3.12.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nbformat": 4,
|
"nbformat": 4,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Seed dataset for the Amazon Connect TEI (Forrester, Feb 2026).
|
Seed dataset for the Amazon Connect TEI (Forrester, Feb 2026).
|
||||||
|
|
||||||
Each row matches the wire shape produced by
|
Each row uses the friendly value shape accepted by
|
||||||
``core.tei_client.TEIClient._normalize_value`` so it can be passed
|
``core.tei_client.TEIClient.update_values`` (see ``_rows_from_value``),
|
||||||
straight to ``client.update_values(public_id, BENEFITS + COSTS)``.
|
so it can be passed straight to
|
||||||
|
``client.update_values(public_id, BENEFITS + COSTS)``.
|
||||||
|
|
||||||
Numbers are the *nominal* (pre-risk-adjustment) values from the PDF —
|
Numbers are the *nominal* (pre-risk-adjustment) values from the PDF —
|
||||||
risk adjustment is stored as a factor and applied by Athena's
|
risk adjustment is stored as a factor and applied by Athena's
|
||||||
|
|||||||
@@ -83,11 +83,71 @@ class TestPaths:
|
|||||||
|
|
||||||
def test_save_version_path(self, client):
|
def test_save_version_path(self, client):
|
||||||
client.session.request.return_value = _mock_response(201, {"version_number": 1})
|
client.session.request.return_value = _mock_response(201, {"version_number": 1})
|
||||||
client.save_version("abc", note="initial")
|
client.save_version("abc", note="initial", date="2026-06-10")
|
||||||
url = self._last_call_url(client)
|
url = self._last_call_url(client)
|
||||||
assert url.endswith("/api/v1/tei/tools/abc/versions/")
|
assert url.endswith("/api/v1/tei/tools/abc/versions/")
|
||||||
body = client.session.request.call_args.kwargs["json"]
|
body = client.session.request.call_args.kwargs["json"]
|
||||||
assert body == {"note": "initial"}
|
assert body == {"date": "2026-06-10", "note": "initial"}
|
||||||
|
|
||||||
|
def test_save_version_defaults_date_to_today(self, client):
|
||||||
|
client.session.request.return_value = _mock_response(201, {"version_number": 1})
|
||||||
|
client.save_version("abc", note="x")
|
||||||
|
body = client.session.request.call_args.kwargs["json"]
|
||||||
|
assert body["note"] == "x"
|
||||||
|
assert len(body["date"]) == 10 # YYYY-MM-DD
|
||||||
|
|
||||||
|
def test_patch_value_year_in_query(self, client):
|
||||||
|
client.session.request.return_value = _mock_response(200, {})
|
||||||
|
client.patch_value("abc", "fkey", year=2, value=100)
|
||||||
|
url = self._last_call_url(client)
|
||||||
|
assert url.endswith("/api/v1/tei/tools/abc/values/fkey/?year=2")
|
||||||
|
body = client.session.request.call_args.kwargs["json"]
|
||||||
|
assert body == {"value": "100"}
|
||||||
|
|
||||||
|
def test_list_clients_path(self, client):
|
||||||
|
client.session.request.return_value = _mock_response(
|
||||||
|
200, {"results": [], "next": None}
|
||||||
|
)
|
||||||
|
client.list_clients(search="acme")
|
||||||
|
assert self._last_call_url(client).endswith("/api/v1/orbit/clients/")
|
||||||
|
assert client.session.request.call_args.kwargs["params"] == {"search": "acme"}
|
||||||
|
|
||||||
|
def test_list_proposals_filters_by_opportunity(self, client):
|
||||||
|
client.session.request.return_value = _mock_response(
|
||||||
|
200, {"results": [], "next": None}
|
||||||
|
)
|
||||||
|
client.list_proposals(opportunity_id=42)
|
||||||
|
assert self._last_call_url(client).endswith("/api/v1/orbit/proposals/")
|
||||||
|
assert client.session.request.call_args.kwargs["params"] == {
|
||||||
|
"opportunity_id": 42
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_list_engagements_path(self, client):
|
||||||
|
client.session.request.return_value = _mock_response(
|
||||||
|
200, {"results": [], "next": None}
|
||||||
|
)
|
||||||
|
client.list_engagements()
|
||||||
|
assert self._last_call_url(client).endswith(
|
||||||
|
"/api/v1/engagement/engagements/"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_proposal_body(self, client):
|
||||||
|
client.session.request.return_value = _mock_response(201, {"id": 7})
|
||||||
|
client.create_proposal("Acme TEI", opportunity_id=42)
|
||||||
|
body = client.session.request.call_args.kwargs["json"]
|
||||||
|
assert body == {"name": "Acme TEI", "opportunity_id": 42, "status": "Draft"}
|
||||||
|
|
||||||
|
def test_reorder_fields_body(self, client):
|
||||||
|
client.session.request.return_value = _mock_response(200, {})
|
||||||
|
client.reorder_fields("rep", [7, 3, 9])
|
||||||
|
body = client.session.request.call_args.kwargs["json"]
|
||||||
|
assert body == {
|
||||||
|
"field_order": [
|
||||||
|
{"id": 7, "sort_order": 1},
|
||||||
|
{"id": 3, "sort_order": 2},
|
||||||
|
{"id": 9, "sort_order": 3},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestErrorHandling:
|
class TestErrorHandling:
|
||||||
@@ -129,39 +189,52 @@ class TestPagination:
|
|||||||
assert [r["id"] for r in out] == [1, 2]
|
assert [r["id"] for r in out] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeValue:
|
class TestRowsFromValue:
|
||||||
|
"""_rows_from_value expands friendly dicts into documented wire rows."""
|
||||||
|
|
||||||
def test_year_underscore_keys(self):
|
def test_year_underscore_keys(self):
|
||||||
out = TEIClient._normalize_value(
|
rows = TEIClient._rows_from_value(
|
||||||
{"field_key": "x", "year_1": 100, "year_2": 200, "risk_adjustment": 0.1}
|
{"field_key": "x", "year_1": 100, "year_2": 200, "risk_adjustment": 0.1}
|
||||||
)
|
)
|
||||||
assert out["year_values"] == {"1": 100.0, "2": 200.0}
|
assert [(r["field_key"], r["year"], r["value"]) for r in rows] == [
|
||||||
assert out["risk_adjustment"] == 0.1
|
("x", 1, "100.0"),
|
||||||
|
("x", 2, "200.0"),
|
||||||
|
]
|
||||||
|
assert all(r["risk_adjustment"] == "0.1" for r in rows)
|
||||||
|
|
||||||
def test_year_values_dict_passthrough(self):
|
def test_year_values_dict(self):
|
||||||
out = TEIClient._normalize_value(
|
rows = TEIClient._rows_from_value(
|
||||||
{
|
{"field_key": "x", "year_values": {"1": 50, "3": 75}, "notes": "hi"}
|
||||||
"field_key": "x",
|
|
||||||
"year_values": {"1": 50, "3": 75},
|
|
||||||
"notes": " hi ",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
assert out["year_values"] == {"1": 50.0, "3": 75.0}
|
assert [(r["year"], r["value"]) for r in rows] == [(1, "50.0"), (3, "75.0")]
|
||||||
assert out["notes"] == " hi "
|
# Notes land on the first year row only.
|
||||||
|
assert rows[0]["notes"] == "hi"
|
||||||
|
assert rows[1]["notes"] is None
|
||||||
|
|
||||||
def test_initial_carried(self):
|
def test_initial_becomes_companion_row(self):
|
||||||
out = TEIClient._normalize_value(
|
rows = TEIClient._rows_from_value(
|
||||||
{"field_key": "x", "initial": 1000, "year_1": 5}
|
{"field_key": "x", "initial": 1000, "year_1": 5}
|
||||||
)
|
)
|
||||||
assert out["initial"] == 1000.0
|
companion = [r for r in rows if r["field_key"] == "x_initial"]
|
||||||
|
assert len(companion) == 1
|
||||||
|
assert companion[0]["year"] is None
|
||||||
|
assert companion[0]["value"] == "1000.0"
|
||||||
|
|
||||||
def test_scalar_value(self):
|
def test_scalar_value(self):
|
||||||
out = TEIClient._normalize_value({"field_key": "rate", "value": 0.10})
|
rows = TEIClient._rows_from_value({"field_key": "rate", "value": 0.10})
|
||||||
assert out["value"] == 0.10
|
assert rows == [
|
||||||
assert "year_values" not in out
|
{
|
||||||
|
"field_key": "rate",
|
||||||
|
"year": None,
|
||||||
|
"value": "0.1",
|
||||||
|
"risk_adjustment": None,
|
||||||
|
"notes": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateValuesPayload:
|
class TestUpdateValuesPayload:
|
||||||
def test_wraps_in_envelope(self, client):
|
def test_flat_rows_in_envelope(self, client):
|
||||||
client.session.request.return_value = _mock_response(200, {})
|
client.session.request.return_value = _mock_response(200, {})
|
||||||
client.update_values(
|
client.update_values(
|
||||||
"abc",
|
"abc",
|
||||||
@@ -169,6 +242,52 @@ class TestUpdateValuesPayload:
|
|||||||
)
|
)
|
||||||
body = client.session.request.call_args.kwargs["json"]
|
body = client.session.request.call_args.kwargs["json"]
|
||||||
assert "values" in body
|
assert "values" in body
|
||||||
assert len(body["values"]) == 2
|
assert len(body["values"]) == 2 # one row per field/year
|
||||||
assert body["values"][0]["field_key"] == "x"
|
assert body["values"][0] == {
|
||||||
assert body["values"][0]["year_values"] == {"1": 100.0}
|
"field_key": "x",
|
||||||
|
"year": 1,
|
||||||
|
"value": "100.0",
|
||||||
|
"risk_adjustment": None,
|
||||||
|
"notes": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetValuesFriendlyShape:
|
||||||
|
def test_documented_years_shape_is_flattened(self, client):
|
||||||
|
client.session.request.return_value = _mock_response(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"tool_id": "abc",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"field_key": "ben",
|
||||||
|
"table": "benefits",
|
||||||
|
"is_annual": True,
|
||||||
|
"risk_adjustment": "0.15",
|
||||||
|
"years": {
|
||||||
|
"1": {"value": "100.00", "risk_adjustment": None, "notes": ""},
|
||||||
|
"2": {"value": "200.00", "risk_adjustment": None, "notes": ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field_key": "cost",
|
||||||
|
"table": "costs",
|
||||||
|
"is_annual": True,
|
||||||
|
"years": {"1": {"value": "10.00"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field_key": "cost_initial",
|
||||||
|
"table": "costs",
|
||||||
|
"is_annual": False,
|
||||||
|
"value": "500.00",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rows = client.get_values("abc")
|
||||||
|
by_key = {r["field_key"]: r for r in rows}
|
||||||
|
assert by_key["ben"]["year_values"] == {"1": 100.0, "2": 200.0}
|
||||||
|
assert by_key["ben"]["risk_adjustment"] == 0.15
|
||||||
|
# companion *_initial folded into parent, not standalone
|
||||||
|
assert "cost_initial" not in by_key
|
||||||
|
assert by_key["cost"]["initial"] == 500.0
|
||||||
|
|||||||
Reference in New Issue
Block a user