feat: add setup notebook and update env example for Athena

This commit is contained in:
2026-06-10 07:02:34 -04:00
parent a2420ed692
commit faa7d20b3e
27 changed files with 2483 additions and 151 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -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
View 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
View 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__

View File

@@ -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

View File

@@ -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}

View File

@@ -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)}")

View File

@@ -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)

View File

@@ -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
View 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

View File

@@ -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.

View File

@@ -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.
""" """

View File

@@ -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)

View File

@@ -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
View 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

Binary file not shown.

BIN
studies/202512_GenesysCX/.DS_Store vendored Normal file

Binary file not shown.

BIN
studies/202602_AmazonConnect/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -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")

View 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
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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