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
ATHENA_BASE_URL=https://athena.nttdata.com
# Copy to .env (gitignored) and fill in — or just run 00_setup.ipynb,
# which prompts for these and writes .env for you.
ATHENA_BASE_URL=https://athena.ouranos.helu.ca
ATHENA_API_KEY=your-api-key-here
# Optional — pre-set the active study + tool so notebooks/CLI pick them up
# without editing config.py.
#
# without editing config.py. 00_provision.ipynb writes these for you.
# A TEI tool attaches to exactly ONE of proposal / engagement.
# PALLADIUM_REPORT_PUBLIC_ID=
# PALLADIUM_TOOL_PUBLIC_ID=
# PALLADIUM_PROPOSAL_ID=
# PALLADIUM_ENGAGEMENT_ID=

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
- Python 3.11+
- Access to Athena API (API key required)
- Jupyter Lab or VS Code with notebook support
### Installation
Palladium is a **Jupyter Lab-first** environment. Everything starts from a
notebook; the Streamlit app and CLI are companions, not prerequisites.
```bash
git clone https://github.com/nttdata/palladium.git
cd palladium
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
make setup # venv + deps + editable install (one time)
make lab # launches Jupyter Lab
```
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
Copy the environment template and add your credentials:
```bash
cp .env.example .env
```
All credentials and IDs live in `<repo>/.env` (gitignored). `00_setup.ipynb`
writes it for you; to do it by hand:
```bash
# .env
ATHENA_BASE_URL=https://athena.nttdata.com
ATHENA_BASE_URL=https://athena.ouranos.helu.ca
ATHENA_API_KEY=your-api-key-here
# written by the provisioning notebook:
PALLADIUM_REPORT_PUBLIC_ID=...
PALLADIUM_TOOL_PUBLIC_ID=...
PALLADIUM_PROPOSAL_ID=... # or PALLADIUM_ENGAGEMENT_ID — a TEI tool
# attaches to exactly one of the two
```
### Verify Connection
In a notebook: `init()` prints the connection status. From a shell:
```bash
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
@@ -103,11 +111,12 @@ Each study lives in `studies/<slug>/`. The reference study is the
February 2026 Forrester *Total Economic Impact™ Of Amazon Connect*:
```bash
jupyter lab studies/202602_AmazonConnect/notebooks/
make lab # then browse to studies/202602_AmazonConnect/notebooks/
```
| Notebook | Purpose |
|----------|---------|
| `00_provision.ipynb` | **Run first** — creates the report template + fields, lets you select the CRM client and the Proposal/Engagement to attach to (pulling the client's profile to avoid re-entry), creates the tool, seeds the published values, calculates, and verifies the totals |
| `01_benefits.ipynb` | Quantify and risk-adjust benefit categories |
| `02_costs.ipynb` | Document implementation and ongoing costs |
| `03_business_case.ipynb` | Financial summary, scenario analysis, visualizations |
@@ -228,7 +237,10 @@ Three scenarios model uncertainty in adoption and realization
```
palladium/
├── 00_setup.ipynb # ← START HERE: credentials + connection
├── Makefile # make setup / lab / app / test
├── core/ # Shared, study-agnostic Python package
│ ├── bootstrap.py # one-import notebook setup (init, save_credentials)
│ ├── tei_client/ # Athena API client
│ │ ├── client.py # TEIClient with all /api/v1/tei/ methods
│ │ └── models.py # Optional dataclasses for typed access
@@ -257,6 +269,7 @@ palladium/
│ ├── config.py # TOOL_PUBLIC_ID, REPORT_PUBLIC_ID
│ ├── seed_data.py # 5 benefits + 3 costs from the PDF
│ ├── notebooks/
│ │ ├── 00_provision.ipynb # creates template+tool in Athena, seeds & verifies
│ │ ├── 01_benefits.ipynb
│ │ ├── 02_costs.ipynb
│ │ ├── 03_business_case.ipynb
@@ -322,6 +335,16 @@ Authorization: Api-Key {your-api-key}
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

View File

@@ -27,7 +27,14 @@ def value_editor(
a notes column. Returns the edited DataFrame; the caller is responsible
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))
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
# CRM lookups, cached briefly so the cascading selects stay snappy.
@st.cache_data(ttl=120, show_spinner=False)
def _crm_clients(_client: TEIClient) -> list[dict]:
try:
return _client.list_clients()
except AthenaAPIError:
return []
@st.cache_data(ttl=120, show_spinner=False)
def _crm_proposals(_client: TEIClient, client_id: int) -> list[dict]:
try:
return _client.proposals_for_client(client_id)
except AthenaAPIError:
return []
@st.cache_data(ttl=120, show_spinner=False)
def _crm_engagements(_client: TEIClient, client_name: str) -> list[dict]:
try:
return _client.engagements_for_client(client_name)
except AthenaAPIError:
return []
def sidebar_tool_picker(client: TEIClient) -> dict | None:
"""Sidebar: pick an existing TEI tool or create one from a report template."""
st.sidebar.title("🛡️ Palladium")
@@ -71,24 +96,70 @@ def sidebar_tool_picker(client: TEIClient) -> dict | None:
else:
report_labels = {f"{r['name']} ({r['vendor']} {r['version']})": r for r in reports}
r_choice = st.selectbox("Report template", list(report_labels.keys()))
new_name = st.text_input("Tool name (optional)", "")
proposal_id = st.number_input(
"Proposal ID (optional)", min_value=0, value=0, step=1
# A TEI tool must attach to a Proposal OR an Engagement.
# Cascade: client → proposal/engagement, pulled from the CRM.
clients = _crm_clients(client)
if not clients:
st.warning("No CRM clients found — create one in Athena first.")
return tool
client_labels = {c["name"]: c for c in clients}
c_choice = st.selectbox("Client", list(client_labels.keys()))
crm_client = client_labels[c_choice]
attach_kind = st.radio(
"Attach to", ["Proposal", "Engagement"], horizontal=True
)
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]
created = _safe_call(
client.create_tool,
report_public_id=report["id"],
proposal=int(proposal_id) or None,
proposal=proposal_id,
engagement=engagement_id,
name=new_name or None,
)
if created:
st.success(f"Created tool {created.get('id')}")
st.cache_data.clear()
st.rerun()
if tool:
st.sidebar.divider()
_opp = tool.get("opportunity") or {}
_client_name = (_opp.get("client") or {}).get("name")
if _client_name:
st.sidebar.markdown(f"**Client**: {_client_name}")
st.sidebar.markdown(f"**Public ID**: `{tool.get('id')}`")
st.sidebar.markdown(f"**Status**: {tool.get('status', '?')}")
st.sidebar.markdown(f"**Version**: {tool.get('current_version', 0)}")

View File

@@ -27,9 +27,14 @@ def render(client: TEIClient, tool: dict) -> None:
st.error(f"Athena API error: {e.detail}")
return
npv = float(summary.get("npv") or 0)
roi = float(summary.get("roi") or summary.get("roi_pct") or 0)
payback = summary.get("payback_months")
npv = float(summary.get("net_present_value") or summary.get("npv") or 0)
roi = float(
summary.get("roi_percentage")
or summary.get("roi")
or summary.get("roi_pct")
or 0
)
payback = summary.get("payback_period_months", summary.get("payback_months"))
bpv = float(summary.get("total_benefits_pv") or 0)
cpv = float(summary.get("total_costs_pv") or 0)
@@ -45,7 +50,16 @@ def render(client: TEIClient, tool: dict) -> None:
st.divider()
# Build the yearly breakdown from the documented per-year summary keys
# (benefits_year_N / costs_year_N) when no pre-built breakdown exists.
yb = summary.get("yearly_breakdown") or []
if not yb:
n = 1
while f"benefits_year_{n}" in summary or f"costs_year_{n}" in summary:
b = float(summary.get(f"benefits_year_{n}") or 0)
c = float(summary.get(f"costs_year_{n}") or 0)
yb.append({"year": n, "benefits": b, "costs": c, "net": b - c})
n += 1
initial = float(summary.get("initial_costs") or 0)
if yb:
charts.cashflow(yb, initial_cost=initial)

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."""
keys = sorted(set(a.keys()) | set(b.keys()))
rows: list[dict] = []
def _years_of(v: dict) -> dict:
"""Accept both friendly (year_values) and wire (nested years) shapes."""
if isinstance(v.get("year_values"), dict):
return {str(k): val for k, val in v["year_values"].items()}
if isinstance(v.get("years"), dict):
return {
str(k): (cell or {}).get("value")
for k, cell in v["years"].items()
}
if v.get("value") is not None:
return {"1": v["value"]}
return {}
for k in keys:
av = a.get(k, {}) or {}
bv = b.get(k, {}) or {}
ay = av.get("year_values") or {}
by = bv.get("year_values") or {}
ay = _years_of(av)
by = _years_of(bv)
years = sorted(set(ay.keys()) | set(by.keys()), key=lambda x: int(x))
for y in years:
a_val = float(ay.get(y) or 0)
@@ -79,8 +92,13 @@ def render(client: TEIClient, tool: dict) -> None:
{
"Version": v.get("version_number"),
"Date": v.get("created_at") or v.get("date"),
"NPV": float(snap.get("npv") or 0),
"ROI %": float(snap.get("roi") or snap.get("roi_pct") or 0),
"NPV": float(snap.get("net_present_value") or snap.get("npv") or 0),
"ROI %": float(
snap.get("roi_percentage")
or snap.get("roi")
or snap.get("roi_pct")
or 0
),
"Note": v.get("note", ""),
}
)

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.
Args:
items: rows shaped like the ``_normalize_value`` output of
:class:`core.tei_client.TEIClient`.
items: friendly value rows (``year_values`` / ``value`` / ``initial``)
as returned by :meth:`core.tei_client.TEIClient.get_values`.
scenario: key into ``multipliers`` (default ``SCENARIOS``).
multipliers: override map. Same shape as ``SCENARIOS``.
table: force a table when items lack one.

View File

@@ -1,8 +1,8 @@
"""
Pandas dataframe builders for benefit / cost / summary tables.
Each builder accepts the value-row dicts produced by
``core.tei_client.TEIClient._normalize_value`` and returns a
Each builder accepts the friendly value-row dicts returned by
``core.tei_client.TEIClient.get_values`` and returns a
nicely-formatted DataFrame for display in notebooks.
"""

View File

@@ -53,6 +53,8 @@ load_dotenv()
logger = logging.getLogger(__name__)
API_PREFIX = "/api/v1/tei"
ORBIT_PREFIX = "/api/v1/orbit"
ENGAGEMENT_PREFIX = "/api/v1/engagement"
class AthenaAPIError(Exception):
@@ -242,6 +244,34 @@ class TEIClient:
"""Get a TEI report template by its 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(
self,
report_public_id: str,
@@ -286,10 +316,18 @@ class TEIClient:
)
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(
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:
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)
# ─────────────────────────────────────────────
#: 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
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
wire-format dict::
Accepted input forms::
# annual fields
{"field_key": "A1", "year_1": 100, "year_2": 200, "year_3": 300, ...}
{"field_key": "A1", "year_values": {"1": 100, "2": 200, "3": 300}, ...}
# annual fields (either shorthand)
{"field_key": "A1", "year_1": 100, "year_2": 200, ...}
{"field_key": "A1", "year_values": {"1": 100, "2": 200}, ...}
# non-annual scalars
{"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",
"year_values": {"1": 100.0, "2": 200.0, "3": 300.0},
"risk_adjustment": 0.15,
"notes": ""}
Output rows look like::
{"field_key": "A1", "year": 1, "value": "100", ...}
{"field_key": "rate", "year": None, "value": "0.10", ...}
"""
out: dict[str, Any] = {}
if "field_key" in value:
out["field_key"] = value["field_key"]
elif "field" in value:
out["field_key"] = value["field"]
field_key = value.get("field_key") or value.get("field")
if not field_key:
return []
# Collect annual year_N keys into year_values
year_values: dict[str, float] = {}
if "year_values" in value and isinstance(value["year_values"], dict):
year_values: dict[int, float] = {}
if isinstance(value.get("year_values"), dict):
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():
if key.startswith("year_"):
try:
n = int(key.split("_", 1)[1])
except ValueError:
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:
out["year_values"] = year_values
if "value" in value and value["value"] is not None and not year_values:
out["value"] = value["value"]
for i, year in enumerate(sorted(year_values)):
row: dict[str, Any] = {
"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:
out["initial"] = float(value["initial"])
if value.get("risk_adjustment") is not None:
out["risk_adjustment"] = float(value["risk_adjustment"])
if value.get("notes"):
out["notes"] = str(value["notes"])
rows.append(
{
"field_key": f"{field_key}{cls.INITIAL_SUFFIX}",
"year": None,
"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
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/")
if isinstance(result, dict):
# Could be {"values": [...]} envelope, the TEITool wrapper, or a page
if "values" in result and isinstance(result["values"], list):
return result["values"]
if "results" in result and isinstance(result["results"], list):
return result["results"]
return []
if isinstance(result, list):
return result
return []
raw_rows = (
result.get("values")
or result.get("results")
or []
)
elif isinstance(result, list):
raw_rows = result
else:
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:
"""
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]}
return self._put(f"{API_PREFIX}/tools/{public_id}/values/", data=payload)
def patch_value(self, public_id: str, field_key: str, **changes) -> dict:
"""
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
rows: list[dict] = []
for v in values:
rows.extend(self._rows_from_value(v))
return self._put(
f"{API_PREFIX}/tools/{public_id}/values/", data={"values": rows}
)
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
# ─────────────────────────────────────────────
@@ -467,11 +747,18 @@ class TEIClient:
return result["versions"]
return []
def save_version(self, public_id: str, note: str = "") -> dict:
"""Snapshot current values + summary as a new version."""
def save_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(
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:
@@ -555,9 +842,11 @@ class TEIClient:
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("" * 56)
print(f" Net Present Value: ${_f(s.get('npv')):>16,.0f}")
print(f" ROI: {_f(s.get('roi')):>15,.0f}%")
payback = s.get("payback_months")
npv = s.get("net_present_value", s.get("npv"))
roi = s.get("roi_percentage", s.get("roi"))
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"
print(f" Payback: {payback_str:>17}")
print("" * 56)

View File

@@ -108,8 +108,8 @@ class TEIValue:
"""
A field value for a specific TEI tool instance.
The exact wire format is not fully pinned in the OpenAPI spec; we use a
convention that the client `_normalize_value` helper builds:
The exact wire format is not fully pinned in the OpenAPI spec; we use
the client's friendly value convention (see ``TEIClient.get_values``):
- annual fields: {field_key, year_values: {"1": ..., "2": ...},
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_YEARS = 3
#: Optional Athena Proposal ID this tool is linked to (when known).
PROPOSAL_ID: int | None = (
int(os.environ["PALLADIUM_PROPOSAL_ID"])
if os.getenv("PALLADIUM_PROPOSAL_ID")
else None
)
def _int_env(name: str) -> int | None:
raw = os.getenv(name, "").strip()
return int(raw) if raw else None
#: Athena Proposal PK this tool is linked to (a TEI tool must attach to a
#: Proposal OR an Engagement — set exactly one).
PROPOSAL_ID: int | None = _int_env("PALLADIUM_PROPOSAL_ID")
#: Athena Engagement PK (alternative attachment point).
ENGAGEMENT_ID: int | None = _int_env("PALLADIUM_ENGAGEMENT_ID")

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",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.7"
"version": "3.12.7"
}
},
"nbformat": 4,

View File

@@ -205,7 +205,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.7"
"version": "3.12.7"
}
},
"nbformat": 4,

View File

@@ -2,6 +2,7 @@
"cells": [
{
"cell_type": "markdown",
"id": "4173501f",
"metadata": {},
"source": [
"# 03 — Business Case\n",
@@ -16,6 +17,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "3cc4b453",
"metadata": {},
"outputs": [],
"source": [
@@ -35,6 +37,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "62c56628",
"metadata": {},
"outputs": [],
"source": [
@@ -47,6 +50,7 @@
},
{
"cell_type": "markdown",
"id": "11fdc7c3",
"metadata": {},
"source": [
"## Local summary (no Athena round-trip)\n",
@@ -58,6 +62,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "7d295b06",
"metadata": {},
"outputs": [],
"source": [
@@ -75,6 +80,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "c3fc75fb",
"metadata": {},
"outputs": [],
"source": [
@@ -84,6 +90,7 @@
},
{
"cell_type": "markdown",
"id": "dd58e4c8",
"metadata": {},
"source": [
"## Cash flow chart\n",
@@ -95,6 +102,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "5d293439",
"metadata": {},
"outputs": [],
"source": [
@@ -106,6 +114,7 @@
},
{
"cell_type": "markdown",
"id": "3b4b7b39",
"metadata": {},
"source": [
"## Waterfall: Benefits PV → Costs PV → NPV"
@@ -114,6 +123,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "04012e0f",
"metadata": {},
"outputs": [],
"source": [
@@ -126,6 +136,7 @@
},
{
"cell_type": "markdown",
"id": "b48db610",
"metadata": {},
"source": [
"## Scenario analysis\n",
@@ -140,6 +151,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "1fb9aa20",
"metadata": {},
"outputs": [],
"source": [
@@ -171,6 +183,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "0ff81b9d",
"metadata": {},
"outputs": [],
"source": [
@@ -179,6 +192,7 @@
},
{
"cell_type": "markdown",
"id": "270745bf",
"metadata": {},
"source": [
"## Cross-check vs Athena (optional)\n",
@@ -190,6 +204,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "c8239dbd",
"metadata": {},
"outputs": [],
"source": [
@@ -206,6 +221,7 @@
},
{
"cell_type": "markdown",
"id": "794848f5",
"metadata": {},
"source": [
"Continue with [`04_export.ipynb`](04_export.ipynb) →"
@@ -213,8 +229,23 @@
}
],
"metadata": {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
"language_info": {"name": "python", "version": "3.11"}
"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

@@ -187,7 +187,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.7"
"version": "3.12.7"
}
},
"nbformat": 4,

View File

@@ -1,9 +1,10 @@
"""
Seed dataset for the Amazon Connect TEI (Forrester, Feb 2026).
Each row matches the wire shape produced by
``core.tei_client.TEIClient._normalize_value`` so it can be passed
straight to ``client.update_values(public_id, BENEFITS + COSTS)``.
Each row uses the friendly value shape accepted by
``core.tei_client.TEIClient.update_values`` (see ``_rows_from_value``),
so it can be passed straight to
``client.update_values(public_id, BENEFITS + COSTS)``.
Numbers are the *nominal* (pre-risk-adjustment) values from the PDF —
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):
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)
assert url.endswith("/api/v1/tei/tools/abc/versions/")
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:
@@ -129,39 +189,52 @@ class TestPagination:
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):
out = TEIClient._normalize_value(
rows = TEIClient._rows_from_value(
{"field_key": "x", "year_1": 100, "year_2": 200, "risk_adjustment": 0.1}
)
assert out["year_values"] == {"1": 100.0, "2": 200.0}
assert out["risk_adjustment"] == 0.1
assert [(r["field_key"], r["year"], r["value"]) for r in rows] == [
("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):
out = TEIClient._normalize_value(
{
"field_key": "x",
"year_values": {"1": 50, "3": 75},
"notes": " hi ",
}
def test_year_values_dict(self):
rows = TEIClient._rows_from_value(
{"field_key": "x", "year_values": {"1": 50, "3": 75}, "notes": "hi"}
)
assert out["year_values"] == {"1": 50.0, "3": 75.0}
assert out["notes"] == " hi "
assert [(r["year"], r["value"]) for r in rows] == [(1, "50.0"), (3, "75.0")]
# Notes land on the first year row only.
assert rows[0]["notes"] == "hi"
assert rows[1]["notes"] is None
def test_initial_carried(self):
out = TEIClient._normalize_value(
def test_initial_becomes_companion_row(self):
rows = TEIClient._rows_from_value(
{"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):
out = TEIClient._normalize_value({"field_key": "rate", "value": 0.10})
assert out["value"] == 0.10
assert "year_values" not in out
rows = TEIClient._rows_from_value({"field_key": "rate", "value": 0.10})
assert rows == [
{
"field_key": "rate",
"year": None,
"value": "0.1",
"risk_adjustment": None,
"notes": None,
}
]
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.update_values(
"abc",
@@ -169,6 +242,52 @@ class TestUpdateValuesPayload:
)
body = client.session.request.call_args.kwargs["json"]
assert "values" in body
assert len(body["values"]) == 2
assert body["values"][0]["field_key"] == "x"
assert body["values"][0]["year_values"] == {"1": 100.0}
assert len(body["values"]) == 2 # one row per field/year
assert body["values"][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