diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1f164b5 Binary files /dev/null and b/.DS_Store differ diff --git a/.env.example b/.env.example index de6fe15..2211e0e 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/00_setup.ipynb b/00_setup.ipynb new file mode 100644 index 0000000..7aad243 --- /dev/null +++ b/00_setup.ipynb @@ -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//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 `/.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 +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9a8e57d --- /dev/null +++ b/Makefile @@ -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__ diff --git a/README.md b/README.md index 9056c25..32e0c88 100644 --- a/README.md +++ b/README.md @@ -46,53 +46,61 @@ Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic --- -## Quick Start +## Quick Start β€” Jupyter Lab first -### Prerequisites - -- 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 `/.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//`. 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 `_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 diff --git a/app/components/tables.py b/app/components/tables.py index a687485..0e35f0b 100644 --- a/app/components/tables.py +++ b/app/components/tables.py @@ -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 "_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} diff --git a/app/main.py b/app/main.py index 6313934..1246907 100644 --- a/app/main.py +++ b/app/main.py @@ -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)}") diff --git a/app/pages/summary.py b/app/pages/summary.py index 4e7e767..eb39f57 100644 --- a/app/pages/summary.py +++ b/app/pages/summary.py @@ -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) diff --git a/app/pages/versions.py b/app/pages/versions.py index f05da77..fb5a34f 100644 --- a/app/pages/versions.py +++ b/app/pages/versions.py @@ -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", ""), } ) diff --git a/core/bootstrap.py b/core/bootstrap.py new file mode 100644 index 0000000..7f362b3 --- /dev/null +++ b/core/bootstrap.py @@ -0,0 +1,184 @@ +""" +Palladium notebook bootstrap β€” one import to set everything up. + +From *any* notebook in the repo (root, ``studies//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 ``/.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 ``/.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) ``/.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 ``/.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 diff --git a/core/calculations/scenarios.py b/core/calculations/scenarios.py index a6ae49a..378f555 100644 --- a/core/calculations/scenarios.py +++ b/core/calculations/scenarios.py @@ -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. diff --git a/core/notebook_helpers/tables.py b/core/notebook_helpers/tables.py index 7ffc30b..ab3473e 100644 --- a/core/notebook_helpers/tables.py +++ b/core/notebook_helpers/tables.py @@ -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. """ diff --git a/core/tei_client/client.py b/core/tei_client/client.py index 65cad60..c46df7c 100644 --- a/core/tei_client/client.py +++ b/core/tei_client/client.py @@ -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 "_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 "_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) diff --git a/core/tei_client/models.py b/core/tei_client/models.py index 34b07c9..9e71d10 100644 --- a/core/tei_client/models.py +++ b/core/tei_client/models.py @@ -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} diff --git a/docs/Athena_TEI.md b/docs/Athena_TEI.md new file mode 100644 index 0000000..def9053 --- /dev/null +++ b/docs/Athena_TEI.md @@ -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 "} +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* diff --git a/studies/.DS_Store b/studies/.DS_Store new file mode 100644 index 0000000..d63d40e Binary files /dev/null and b/studies/.DS_Store differ diff --git a/studies/202512_GenesysCX/.DS_Store b/studies/202512_GenesysCX/.DS_Store new file mode 100644 index 0000000..b4e6492 Binary files /dev/null and b/studies/202512_GenesysCX/.DS_Store differ diff --git a/studies/202512_GenesysCX/docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf b/studies/202512_GenesysCX/docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf new file mode 100644 index 0000000..232b1fd Binary files /dev/null and b/studies/202512_GenesysCX/docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf differ diff --git a/studies/202602_AmazonConnect/.DS_Store b/studies/202602_AmazonConnect/.DS_Store new file mode 100644 index 0000000..b1bc858 Binary files /dev/null and b/studies/202602_AmazonConnect/.DS_Store differ diff --git a/studies/202602_AmazonConnect/config.py b/studies/202602_AmazonConnect/config.py index 93e764a..98466ad 100644 --- a/studies/202602_AmazonConnect/config.py +++ b/studies/202602_AmazonConnect/config.py @@ -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") diff --git a/studies/202602_AmazonConnect/notebooks/00_provision.ipynb b/studies/202602_AmazonConnect/notebooks/00_provision.ipynb new file mode 100644 index 0000000..7e447f2 --- /dev/null +++ b/studies/202602_AmazonConnect/notebooks/00_provision.ipynb @@ -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 `_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 +} diff --git a/studies/202602_AmazonConnect/notebooks/01_benefits.ipynb b/studies/202602_AmazonConnect/notebooks/01_benefits.ipynb index 90208d9..be2e836 100644 --- a/studies/202602_AmazonConnect/notebooks/01_benefits.ipynb +++ b/studies/202602_AmazonConnect/notebooks/01_benefits.ipynb @@ -261,7 +261,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.7" + "version": "3.12.7" } }, "nbformat": 4, diff --git a/studies/202602_AmazonConnect/notebooks/02_costs.ipynb b/studies/202602_AmazonConnect/notebooks/02_costs.ipynb index e4ea7aa..d5528a2 100644 --- a/studies/202602_AmazonConnect/notebooks/02_costs.ipynb +++ b/studies/202602_AmazonConnect/notebooks/02_costs.ipynb @@ -205,7 +205,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.7" + "version": "3.12.7" } }, "nbformat": 4, diff --git a/studies/202602_AmazonConnect/notebooks/03_business_case.ipynb b/studies/202602_AmazonConnect/notebooks/03_business_case.ipynb index 1ed2c87..db88c05 100644 --- a/studies/202602_AmazonConnect/notebooks/03_business_case.ipynb +++ b/studies/202602_AmazonConnect/notebooks/03_business_case.ipynb @@ -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 diff --git a/studies/202602_AmazonConnect/notebooks/04_export.ipynb b/studies/202602_AmazonConnect/notebooks/04_export.ipynb index ce8b15c..4c60212 100644 --- a/studies/202602_AmazonConnect/notebooks/04_export.ipynb +++ b/studies/202602_AmazonConnect/notebooks/04_export.ipynb @@ -187,7 +187,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.7" + "version": "3.12.7" } }, "nbformat": 4, diff --git a/studies/202602_AmazonConnect/seed_data.py b/studies/202602_AmazonConnect/seed_data.py index 9142cf6..5ddc000 100644 --- a/studies/202602_AmazonConnect/seed_data.py +++ b/studies/202602_AmazonConnect/seed_data.py @@ -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 diff --git a/tests/test_client.py b/tests/test_client.py index f59d048..f08ab9d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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