refactor: restructure repo into core/app modules with per-study folders

Reorganize Palladium codebase into a modular architecture with `core/`
shared logic and `app/` Streamlit UI, separating per-study assets into
`studies/YYYYMM_<Vendor>/` folders containing notebooks, seed data, and
configuration. Update README to reflect new structure, add `.gitignore`
entries for `.env` and study exports, and refresh component documentation.
This commit is contained in:
2026-05-20 22:28:12 -04:00
parent a6f3ee3676
commit a2420ed692
52 changed files with 35300 additions and 105 deletions

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Athena API
ATHENA_BASE_URL=https://athena.nttdata.com
ATHENA_API_KEY=your-api-key-here
# Optional — pre-set the active study + tool so notebooks/CLI pick them up
# without editing config.py.
#
# PALLADIUM_REPORT_PUBLIC_ID=
# PALLADIUM_TOOL_PUBLIC_ID=
# PALLADIUM_PROPOSAL_ID=

5
.gitignore vendored
View File

@@ -5,6 +5,11 @@
.ipynb_checkpoints
*/.ipynb_checkpoints/*
# Palladium-specific
.env
studies/*/exports/*
!studies/*/exports/.gitkeep
# IPython
profile_default/
ipython_config.py

284
README.md
View File

@@ -2,45 +2,47 @@
**TEI (Total Economic Impact) Calculator** — The strategic artifact that protects the business case.
Palladium is a Jupyter notebook-based calculator and Streamlit application for building Total Economic Impact analyses. It connects to [Athena](https://athena.nttdata.com) for data persistence, performs financial calculations (NPV, ROI, payback period), and exports structured data for the report generation pipeline.
Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic Impact analyses. It connects to [Athena](https://athena.nttdata.com) for data persistence, performs financial calculations (NPV, ROI, payback period), and exports structured data for the report generation pipeline.
> *In Greek mythology, the Palladium was a sacred artifact of Athena that protected Troy. Whoever possessed it held strategic advantage. In our ecosystem, Palladium protects the deal — transforming discovery inputs into a financial case no CFO can ignore.*
## Architecture
```
┌─────────────────────────────────────────────────────────┐
┌──────────────────────────────────────────────────────────────────
│ Palladium │
│ │
┌──────────────┐ ┌──────────────┐ ┌───────────┐
│ Notebooks Streamlit Export
│ (Analysis) │ (Data Entry)│ │ (Report) │
└──────┬───────┘ └──────┬───────┘ └─────┬─────┘
└───────────┬───────┘ │
┌──────────────────┐
TEI Client │
(API Layer) ──────────────────┘ │
└────────┬─────────┘
└────────────────────┼────────────────────────────────────┘
─────────────┐ ┌──────────────────┐
│ Athena Report Pipeline
(API) (html2docx)
└─────────────┘ └──────────────────┘
studies/202602_AmazonConnect/ ← one folder per TEI study
studies/YYYYMM_<Vendor>/
├─ notebooks/ ─┐
├─ seed_data.py │
└─ config.py
┌──────────────┐ ┌──────────────┐
core/ │ ←─ │ app/ │
│ shared logic │ Streamlit │
─────────────┘ └──────┬───────┘
│ │
│ ▼ ▼ │
tei_client → ───────────────────► Athena API
calculations │
export ──────────────────────────► export.json │
notebook_helpers
│ cli
└──────────────────────────────────────────────────────────────────┘
```
### Components
| Component | Purpose |
|-----------|---------|
| **TEI Client** | Python API client for Athena's TEI endpoints |
| **Calculations** | Financial logic — NPV, ROI, payback, risk adjustment |
| **Notebooks** | Interactive analysis — benefits, costs, business case |
| **Streamlit App** | Data entry UI with version management |
| **Export** | Structured JSON for the LLM report generation pipeline |
| **`core/tei_client`** | Python API client for Athena's TEI endpoints |
| **`core/calculations`** | Financial logic — NPV, ROI, payback, risk adjustment, scenarios |
| **`core/export`** | Builds the structured JSON envelope consumed by the report pipeline |
| **`core/notebook_helpers`** | Pandas tables, Plotly charts, IPython display widgets |
| **`core/cli`** | `python -m palladium` command-line interface |
| **`app/`** | Streamlit data-entry UI with version management — *study-agnostic* |
| **`studies/`** | One folder per TEI engagement (notebooks, seed data, config, source PDF) |
---
@@ -82,26 +84,26 @@ ATHENA_API_KEY=your-api-key-here
python -m palladium test
```
Or in a notebook:
Or in Python:
```python
from tei_client import TEIClient
from core.tei_client import TEIClient
client = TEIClient()
result = client.test_connection()
print(result) # {'status': 'ok', 'authenticated': True, ...}
print(client.test_connection()) # {'status': 'ok', 'authenticated': True, ...}
```
---
## Usage
### Jupyter Notebooks
### Run a study end-to-end
The primary workflow for TEI analysis:
Each study lives in `studies/<slug>/`. The reference study is the
February 2026 Forrester *Total Economic Impact™ Of Amazon Connect*:
```bash
jupyter lab notebooks/
jupyter lab studies/202602_AmazonConnect/notebooks/
```
| Notebook | Purpose |
@@ -109,11 +111,15 @@ jupyter lab notebooks/
| `01_benefits.ipynb` | Quantify and risk-adjust benefit categories |
| `02_costs.ipynb` | Document implementation and ongoing costs |
| `03_business_case.ipynb` | Financial summary, scenario analysis, visualizations |
| `04_export.ipynb` | Generate report-ready JSON for html2docx pipeline |
| `04_export.ipynb` | Generate report-ready JSON for the html2docx pipeline |
### Streamlit Application
The Amazon Connect notebooks reproduce the published study totals within
rounding: **NPV $78.7M • ROI 342% • Payback <6 months**.
Interactive UI for data entry and version management:
### Streamlit application (study-agnostic)
Interactive UI for data entry and version management. Works for any TEI
study because field definitions come from Athena at runtime:
```bash
streamlit run app/main.py
@@ -125,21 +131,56 @@ streamlit run app/main.py
# Test connection
python -m palladium test
# List TEI instances
# List TEI tool instances
python -m palladium list
# Show financial summary
# List available report templates
python -m palladium reports
# Show financial summary for a tool
python -m palladium summary <public_id>
# Export for report pipeline
# Trigger server-side recalculation
python -m palladium calculate <public_id>
# Export for the report pipeline
python -m palladium export <public_id> -o export.json
```
### Tests
```bash
pytest tests/ -v
```
50 tests cover the API client (mocked HTTP), the financial math, and the
export envelope shape. The Amazon Connect seed data is asserted against
the published Forrester totals.
---
## Adding a new study
```bash
cp -r studies/202602_AmazonConnect studies/202612_GenesysCloud
cd studies/202612_GenesysCloud
```
1. **`README.md`** — update the title, source citation, key numbers.
2. **`seed_data.py`** — replace `BENEFITS` and `COSTS` with the new study's rows.
3. **`config.py`** — set `STUDY_SLUG`, leave `TOOL_PUBLIC_ID` blank until provisioned.
4. **`docs/`** — drop the source PDF here.
5. Open the notebooks; the imports (`core.calculations`, `core.notebook_helpers`,
`core.tei_client`) are study-agnostic. Update the markdown narrative.
The shared `core/` package and the `app/` Streamlit UI need no changes —
they introspect the TEI Report template via the API.
---
## TEI Methodology
Palladium implements the Forrester TEI™ framework [1]:
Palladium implements the Forrester TEI™ framework.
### Benefit Categories
@@ -154,25 +195,32 @@ Benefits are quantified across categories, risk-adjusted, and discounted to pres
### Risk Adjustment
Each benefit carries a risk adjustment factor (050%) reflecting implementation uncertainty. A 20% risk adjustment on a $10M benefit yields a risk-adjusted value of $8M.
Each benefit carries a risk-adjustment factor (050%) reflecting implementation uncertainty.
A 20% risk adjustment on a $10M benefit yields a risk-adjusted value of $8M.
**Costs** are risk-adjusted **upward** by the same factor (higher risk → higher modelled cost).
### Financial Metrics
| Metric | Description |
|--------|-------------|
| **NPV** | Net Present Value — total risk-adjusted benefits minus costs, discounted |
| **ROI** | Return on Investment — (benefits - costs) / costs × 100 |
| **ROI** | Return on Investment — `(benefits costs) / costs × 100` |
| **Payback** | Months until cumulative benefits exceed cumulative costs |
The initial investment (year 0) is **not** discounted. Year-N cashflows are
discounted at the end of the year: `PV = CF_n / (1 + r)^n`. This matches
the Forrester methodology used in the published studies.
### Scenario Analysis
Three scenarios model uncertainty in adoption and realization:
Three scenarios model uncertainty in adoption and realization
(see `core.calculations.SCENARIOS`):
| Scenario | Approach |
|----------|----------|
| Conservative | Higher risk adjustments, lower adoption rates |
| Moderate | Balanced assumptions (base case) |
| Aggressive | Lower risk adjustments, faster adoption |
| Scenario | Adoption | Risk delta | Effect |
|----------|----------|------------|--------|
| Conservative | 80% | +10pp on benefits | Lower benefits, higher modelled cost |
| Moderate | 100% | 0 | Base case (= published study) |
| Aggressive | 115% | 5pp on benefits | Higher benefits, lower padding on cost |
---
@@ -180,40 +228,48 @@ Three scenarios model uncertainty in adoption and realization:
```
palladium/
├── app/ # Streamlit application
│ ├── main.py # App entry point
│ ├── pages/
│ │ ── benefits.py # Benefits data entry
│ ├── costs.py # Costs data entry
│ │ ├── summary.py # Financial summary dashboard
│ │ ── versions.py # Version history & comparison
└── components/
── charts.py # Visualization components
└── tables.py # Data table components
├── notebooks/ # Jupyter analysis notebooks
│ ├── 01_benefits.ipynb
│ ├── 02_costs.ipynb
│ ├── 03_business_case.ipynb
│ └── 04_export.ipynb
├── tei_client/ # Athena API client
├── __init__.py
│ ├── client.py # HTTP client with auth
│ └── models.py # Response data models
├── calculations/ # Financial calculation engine
│ ├── __init__.py
│ ├── npv.py # Net present value
── roi.py # Return on investment
│ ├── payback.py # Payback period
│ └── scenarios.py # Scenario multipliers
├── export/ # Report pipeline export
├── __init__.py
└── report_data.py # JSON export for html2docx
├── tests/
├── core/ # Shared, study-agnostic Python package
│ ├── tei_client/ # Athena API client
│ ├── client.py # TEIClient with all /api/v1/tei/ methods
│ │ ── models.py # Optional dataclasses for typed access
│ ├── calculations/ # Pure-python financial math
│ │ ├── npv.py
│ │ ── roi.py
│ ├── payback.py
── scenarios.py
├── export/
└── report_data.py # JSON envelope for the report pipeline
│ ├── notebook_helpers/
│ ├── tables.py # Pandas dataframe builders
│ ├── charts.py # Plotly figures
│ └── display.py # IPython KPI cards, alerts
│ └── cli/
└── main.py # `python -m palladium ...`
├── palladium/ # CLI shim (just exposes `python -m palladium`)
│ └── __main__.py
├── app/ # Streamlit UI — works with any TEI study
│ ├── main.py # entry point
│ ├── pages/ # benefits, costs, summary, versions
── components/ # tables, charts
├── studies/ # One folder per TEI engagement
│ └── 202602_AmazonConnect/
│ ├── README.md
├── config.py # TOOL_PUBLIC_ID, REPORT_PUBLIC_ID
├── seed_data.py # 5 benefits + 3 costs from the PDF
│ ├── notebooks/
│ │ ├── 01_benefits.ipynb
│ │ ├── 02_costs.ipynb
│ │ ├── 03_business_case.ipynb
│ │ └── 04_export.ipynb
│ ├── exports/ # generated; .gitignored
│ └── docs/
│ └── 202602_TEI Report Amazon Connect.pdf
├── tests/ # 50 tests for core/
│ ├── test_client.py
│ ├── test_calculations.py
│ └── test_export.py
├── Athena API.yaml # OpenAPI reference
├── .env.example
├── .gitignore
├── requirements.txt
├── pyproject.toml
└── README.md
@@ -227,18 +283,36 @@ Palladium connects to Athena's TEI module for data persistence and cross-tool re
### API Endpoints Used
All endpoints are under `/api/v1/tei/` and require `Authorization: Api-Key {key}`.
| Endpoint | Purpose |
|----------|---------|
| `GET /forge/api/tei/reports/` | List available TEI model templates |
| `GET /forge/api/tei/reports/{id}/fields/` | Get field definitions for a model |
| `POST /forge/api/tei/tools/` | Create new TEI instance |
| `GET /forge/api/tei/tools/{public_id}/` | Get instance metadata |
| `GET /forge/api/tei/tools/{public_id}/values/` | Get current field values |
| `PUT /forge/api/tei/tools/{public_id}/values/` | Bulk update values |
| `POST /forge/api/tei/tools/{public_id}/calculate/` | Trigger calculation |
| `GET /forge/api/tei/tools/{public_id}/summary/` | Get financial summary |
| `POST /forge/api/tei/tools/{public_id}/versions/` | Save version snapshot |
| `GET /forge/api/tei/tools/{public_id}/export/` | Export for report pipeline |
| `GET /api/v1/tei/reports/` | List available TEI report templates |
| `GET /api/v1/tei/reports/{public_id}/` | Get a report template |
| `GET /api/v1/tei/reports/{public_id}/fields/` | Get field definitions for a template |
| `POST /api/v1/tei/tools/` | Create a new TEI tool instance |
| `GET /api/v1/tei/tools/{public_id}/` | Get instance metadata |
| `PATCH /api/v1/tei/tools/{public_id}/` | Update name/status |
| `GET /api/v1/tei/tools/{public_id}/values/` | Get current field values |
| `PUT /api/v1/tei/tools/{public_id}/values/` | Bulk-update values |
| `PATCH /api/v1/tei/tools/{public_id}/values/{field_key}/` | Patch a single value |
| `POST /api/v1/tei/tools/{public_id}/calculate/` | Trigger calculation |
| `GET /api/v1/tei/tools/{public_id}/summary/` | Get financial summary |
| `GET /api/v1/tei/tools/{public_id}/versions/` | List version snapshots |
| `POST /api/v1/tei/tools/{public_id}/versions/` | Save a new version |
| `GET /api/v1/tei/tools/{public_id}/versions/{n}/` | Get a specific version |
| `GET /api/v1/tei/tools/{public_id}/export/` | Export for the report pipeline |
| `GET /api/v1/tei/summary/` | Aggregate NPV across all tools |
### Object model
| Athena object | Notes |
|---|---|
| **Opportunity** | Top-level sales record. Owns one or more **Proposals**. |
| **Proposal** | A specific bid/offer to a client. **A TEI tool is linked to a Proposal.** |
| **Engagement** | Optional — for active client engagements. A TEI tool may also link here. |
| **TEIReport** | Template (e.g. *Amazon Connect 2026*) — defines fields, discount rate, analysis horizon. |
| **TEITool** | Instance of a Report bound to a Proposal — holds values, summaries, versions. |
### Authentication
@@ -258,7 +332,7 @@ Palladium's export produces structured JSON consumed by the LLM report generatio
Palladium Export (JSON)
LLM generates HTML (following HTML_DOCUMENT_FORMAT.md)
Peitho — LLM generates HTML (following HTML_DOCUMENT_FORMAT.md)
html2docx converts to native Word
@@ -267,23 +341,23 @@ html2docx converts to native Word
Professional TEI Report (.docx)
```
The export JSON includes:
The export envelope (`core.export.build_report_data`) includes:
- All benefit categories with risk-adjusted values
- All cost categories with yearly breakdown
- Financial summary (NPV, ROI, payback)
- Yearly cash flow data (for waterfall/bar charts)
- Scenario analysis results (conservative/moderate/aggressive)
- Metadata (client, opportunity, analysis period, discount rate)
- All cost categories with yearly breakdown (and Initial column)
- Financial summary (NPV, ROI, payback, yearly cashflow)
- Conservative / moderate / aggressive scenario analysis
- Metadata (study slug, proposal, engagement, generator stamp)
- The raw Athena `/export/` payload for reference
---
## Version Management
Palladium manages version history through the Streamlit UI:
Palladium manages version history through both the API and the Streamlit UI:
1. **Save Version** — Snapshots current values + financial summary with a descriptive note
2. **View History**See all versions with headline metrics (NPV, ROI)
3. **Compare Versions** — Side-by-side diff showing what changed between any two versions
1. **Save Version** — Snapshots current values + summary with a descriptive note
2. **View History**All versions with headline metrics (NPV, ROI)
3. **Compare Versions** — Side-by-side diff of value changes between any two versions
4. **Restore Version** — Load a previous version's values as the current state
Version notes should capture:
@@ -302,6 +376,10 @@ Version notes should capture:
pytest tests/ -v
```
Tests are designed to run without an Athena connection — HTTP is mocked
and the calculation suite uses the Amazon Connect seed data to verify the
Forrester numbers reproduce within rounding.
### Code Style
```bash
@@ -311,10 +389,11 @@ ruff format .
### Adding a New Benefit Category
1. Define the field in Athena's TEI Model admin (field name, type, category, defaults)
2. The field automatically appears in Palladium via the API
3. Update notebook analysis if category-specific logic is needed
4. Update export mapping if the report template expects specific structure
1. Define the field in Athena's TEI Report admin (field name, type, category, defaults)
2. The field automatically appears in Palladium via the API — no client changes
3. Update notebook prose if category-specific commentary is needed
4. If the report template exposes a new structure, extend the envelope in
`core/export/report_data.py`
---
@@ -341,4 +420,3 @@ ruff format .
| **Athena** | Platform API — data persistence, cross-tool reporting |
| **Peitho** | Document generation — consumes Palladium's export JSON |
| **html2docx** | Converts LLM-generated HTML to native Word documents |

0
app/__init__.py Normal file
View File

View File

32
app/components/charts.py Normal file
View File

@@ -0,0 +1,32 @@
"""Streamlit-friendly chart wrappers (delegate to core.notebook_helpers.charts)."""
from __future__ import annotations
import streamlit as st
from core.notebook_helpers import charts as core_charts
def cashflow(yearly_breakdown, *, initial_cost: float = 0.0) -> None:
fig = core_charts.cashflow_chart(yearly_breakdown, initial_cost=initial_cost)
st.plotly_chart(fig, use_container_width=True)
def benefits_bar(items) -> None:
fig = core_charts.benefits_bar(items)
st.plotly_chart(fig, use_container_width=True)
def cost_pie(items) -> None:
fig = core_charts.cost_breakdown_pie(items)
st.plotly_chart(fig, use_container_width=True)
def scenario_bars(scenarios) -> None:
fig = core_charts.scenario_comparison(scenarios)
st.plotly_chart(fig, use_container_width=True)
def waterfall(values) -> None:
fig = core_charts.waterfall(values)
st.plotly_chart(fig, use_container_width=True)

106
app/components/tables.py Normal file
View File

@@ -0,0 +1,106 @@
"""Streamlit data-editor wrappers for benefit/cost rows."""
from __future__ import annotations
import pandas as pd
import streamlit as st
def _years_for_table(fields: list[dict], analysis_years: int) -> list[int]:
"""Years 1..N — taken from analysis_period_years on the report."""
return list(range(1, max(int(analysis_years or 3), 1) + 1))
def value_editor(
table: str,
fields: list[dict],
values: list[dict],
*,
analysis_years: int,
key: str,
) -> pd.DataFrame:
"""
Render an ``st.data_editor`` for benefit or cost values.
The editor shows one row per field (filtered to ``table``), with year
columns, an ``initial`` column for costs, a risk_adjustment column, and
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.sort(key=lambda f: int(f.get("sort_order") or 0))
by_key = {v.get("field_key"): v for v in values}
years = _years_for_table(fields, analysis_years)
rows: list[dict] = []
for f in fields:
v = by_key.get(f["field_key"], {}) or {}
yv = v.get("year_values") or {}
row = {
"field_key": f["field_key"],
"label": f.get("label", f["field_key"]),
"category": f.get("category", "") or "",
}
if table == "costs":
row["Initial"] = float(v.get("initial") or 0.0)
for y in years:
row[f"Year {y}"] = float(yv.get(str(y)) or 0.0)
row["risk_adj"] = float(v.get("risk_adjustment") or 0.0)
row["notes"] = v.get("notes", "") or ""
rows.append(row)
df = pd.DataFrame(rows)
column_config: dict = {
"field_key": st.column_config.TextColumn("Key", disabled=True, width="small"),
"label": st.column_config.TextColumn("Field", disabled=True),
"category": st.column_config.TextColumn("Category", disabled=True, width="small"),
"risk_adj": st.column_config.NumberColumn(
"Risk Adj.", min_value=0.0, max_value=1.0, step=0.05, format="%.2f"
),
"notes": st.column_config.TextColumn("Notes", width="medium"),
}
if table == "costs":
column_config["Initial"] = st.column_config.NumberColumn(
"Initial", format="$%.0f"
)
for y in years:
column_config[f"Year {y}"] = st.column_config.NumberColumn(
f"Year {y}", format="$%.0f"
)
edited = st.data_editor(
df,
column_config=column_config,
use_container_width=True,
num_rows="fixed",
hide_index=True,
key=key,
)
return edited
def df_to_values(df: pd.DataFrame, table: str, analysis_years: int) -> list[dict]:
"""Convert an edited DataFrame back to wire-format value rows."""
out: list[dict] = []
years = list(range(1, max(int(analysis_years or 3), 1) + 1))
for _, row in df.iterrows():
item: dict = {"field_key": row["field_key"], "table": table}
yv = {}
for y in years:
col = f"Year {y}"
if col in df.columns:
yv[str(y)] = float(row[col] or 0)
if yv:
item["year_values"] = yv
if table == "costs" and "Initial" in df.columns:
item["initial"] = float(row["Initial"] or 0)
ra = row.get("risk_adj")
if ra is not None and not pd.isna(ra):
item["risk_adjustment"] = float(ra)
notes = row.get("notes")
if isinstance(notes, str) and notes.strip():
item["notes"] = notes.strip()
out.append(item)
return out

138
app/main.py Normal file
View File

@@ -0,0 +1,138 @@
"""
Palladium Streamlit app — TEI data entry, calculation, versioning, export.
Run from the project root::
streamlit run app/main.py
The app picks a TEI tool by ``public_id`` (or creates one from a Report
template) and exposes Benefits, Costs, Summary, and Versions pages. It is
study-agnostic — the field set is loaded dynamically from Athena based on
the linked Report template.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Allow `streamlit run app/main.py` from project root without `pip install -e .`
_ROOT = Path(__file__).resolve().parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
import streamlit as st
from core.tei_client import AthenaAPIError, TEIClient
st.set_page_config(
page_title="Palladium — TEI Calculator",
page_icon="🛡️",
layout="wide",
)
@st.cache_resource(show_spinner=False)
def get_client() -> TEIClient:
return TEIClient()
def _safe_call(fn, *args, **kwargs):
"""Run an API call, surfacing errors as Streamlit messages."""
try:
return fn(*args, **kwargs)
except AthenaAPIError as e:
st.error(f"Athena API error {e.status_code}: {e.detail}")
except ValueError as e:
st.error(str(e))
return None
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")
st.sidebar.caption("TEI Calculator")
tools = _safe_call(client.list_tools) or []
if tools:
labels = {
f"{t.get('name', '(unnamed)')}{t.get('id', '')[:8]}": t for t in tools
}
choice = st.sidebar.selectbox("TEI Tool", list(labels.keys()))
tool = labels[choice]
else:
st.sidebar.info("No TEI tools yet. Create one below.")
tool = None
with st.sidebar.expander("Create new tool"):
reports = _safe_call(client.list_reports) or []
if not reports:
st.write("No report templates available.")
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
)
if st.button("Create"):
report = report_labels[r_choice]
created = _safe_call(
client.create_tool,
report_public_id=report["id"],
proposal=int(proposal_id) or None,
name=new_name or None,
)
if created:
st.success(f"Created tool {created.get('id')}")
st.rerun()
if tool:
st.sidebar.divider()
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)}")
if st.sidebar.button("🔄 Recalculate"):
_safe_call(client.calculate, tool["id"])
st.toast("Recalculated.", icon="")
st.cache_data.clear()
return tool
def main() -> None:
st.title("Palladium — TEI Calculator")
try:
client = get_client()
except ValueError as e:
st.error(str(e))
st.info("Set ATHENA_BASE_URL and ATHENA_API_KEY in your `.env` file.")
st.stop()
return
tool = sidebar_tool_picker(client)
if tool is None:
st.info("Pick or create a TEI tool from the sidebar to begin.")
return
# Tab navigation — matches `app/pages/*` modules but kept as tabs so all
# views share the chosen tool/state without re-querying.
tabs = st.tabs(["📊 Summary", "💰 Benefits", "💸 Costs", "🕒 Versions"])
from app.pages import benefits as benefits_page
from app.pages import costs as costs_page
from app.pages import summary as summary_page
from app.pages import versions as versions_page
with tabs[0]:
summary_page.render(client, tool)
with tabs[1]:
benefits_page.render(client, tool)
with tabs[2]:
costs_page.render(client, tool)
with tabs[3]:
versions_page.render(client, tool)
if __name__ == "__main__":
main()

0
app/pages/__init__.py Normal file
View File

28
app/pages/_helpers.py Normal file
View File

@@ -0,0 +1,28 @@
"""Common helpers shared by the page modules."""
from __future__ import annotations
import streamlit as st
from core.tei_client import AthenaAPIError, TEIClient
def report_meta(client: TEIClient, tool: dict) -> dict:
"""Fetch the linked report (handles both nested-object and id-only forms)."""
report_obj = tool.get("report")
if isinstance(report_obj, dict):
return report_obj
if isinstance(report_obj, str):
try:
return client.get_report(report_obj)
except AthenaAPIError as e:
st.error(f"Failed to load report template: {e}")
return {}
def safe(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except AthenaAPIError as e:
st.error(f"Athena API error {e.status_code}: {e.detail}")
return None

46
app/pages/benefits.py Normal file
View File

@@ -0,0 +1,46 @@
"""Benefits data-entry tab."""
from __future__ import annotations
import streamlit as st
from app.components.tables import df_to_values, value_editor
from app.pages._helpers import report_meta, safe
from core.tei_client import TEIClient
def render(client: TEIClient, tool: dict) -> None:
st.header("💰 Benefits")
public_id = tool["id"]
report = report_meta(client, tool)
analysis_years = int(report.get("analysis_period_years") or 3)
fields = safe(client.list_fields, report.get("id"), "benefits") or []
values = [v for v in safe(client.get_values, public_id) or [] if v.get("table") == "benefits"]
if not fields:
st.info("This report template has no benefit fields defined.")
return
edited = value_editor(
"benefits",
fields,
values,
analysis_years=analysis_years,
key=f"benefits_editor_{public_id}",
)
col1, col2 = st.columns([1, 4])
with col1:
if st.button("💾 Save benefits", use_container_width=True):
payload = df_to_values(edited, "benefits", analysis_years)
result = safe(client.update_values, public_id, payload)
if result is not None:
st.success(f"Saved {len(payload)} benefit values.")
st.cache_data.clear()
with col2:
st.caption(
"Values are saved as nominal annual amounts. Risk adjustments are "
"applied at calculate time. Use the Recalculate button in the "
"sidebar after saving to refresh the summary."
)

46
app/pages/costs.py Normal file
View File

@@ -0,0 +1,46 @@
"""Costs data-entry tab."""
from __future__ import annotations
import streamlit as st
from app.components.tables import df_to_values, value_editor
from app.pages._helpers import report_meta, safe
from core.tei_client import TEIClient
def render(client: TEIClient, tool: dict) -> None:
st.header("💸 Costs")
public_id = tool["id"]
report = report_meta(client, tool)
analysis_years = int(report.get("analysis_period_years") or 3)
fields = safe(client.list_fields, report.get("id"), "costs") or []
values = [v for v in safe(client.get_values, public_id) or [] if v.get("table") == "costs"]
if not fields:
st.info("This report template has no cost fields defined.")
return
edited = value_editor(
"costs",
fields,
values,
analysis_years=analysis_years,
key=f"costs_editor_{public_id}",
)
col1, col2 = st.columns([1, 4])
with col1:
if st.button("💾 Save costs", use_container_width=True):
payload = df_to_values(edited, "costs", analysis_years)
result = safe(client.update_values, public_id, payload)
if result is not None:
st.success(f"Saved {len(payload)} cost values.")
st.cache_data.clear()
with col2:
st.caption(
"The Initial column is undiscounted year-0 spend. Year columns "
"are end-of-year cashflows. Costs are risk-adjusted upward "
"(higher risk → higher cost)."
)

104
app/pages/summary.py Normal file
View File

@@ -0,0 +1,104 @@
"""Financial summary dashboard tab."""
from __future__ import annotations
import streamlit as st
from app.components import charts
from app.pages._helpers import report_meta, safe
from core.export import build_report_data
from core.tei_client import AthenaAPIError, TEIClient
def render(client: TEIClient, tool: dict) -> None:
st.header("📊 Financial Summary")
public_id = tool["id"]
report = report_meta(client, tool)
try:
summary = client.get_summary(public_id)
except AthenaAPIError as e:
if e.status_code == 404:
st.info(
"No summary yet — click **Recalculate** in the sidebar after "
"filling in benefits and costs."
)
return
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")
bpv = float(summary.get("total_benefits_pv") or 0)
cpv = float(summary.get("total_costs_pv") or 0)
cols = st.columns(5)
cols[0].metric("NPV", f"${npv/1_000_000:,.1f}M")
cols[1].metric("ROI", f"{roi:,.0f}%")
cols[2].metric(
"Payback",
f"{float(payback):.1f} months" if payback is not None else "N/A",
)
cols[3].metric("Benefits PV", f"${bpv/1_000_000:,.1f}M")
cols[4].metric("Costs PV", f"${cpv/1_000_000:,.1f}M")
st.divider()
yb = summary.get("yearly_breakdown") or []
initial = float(summary.get("initial_costs") or 0)
if yb:
charts.cashflow(yb, initial_cost=initial)
with st.expander("Cash flow table"):
st.dataframe(yb, use_container_width=True, hide_index=True)
else:
st.caption("No yearly breakdown in this summary.")
# Scenario comparison — computed locally from current values
with st.expander("Scenario analysis (conservative / moderate / aggressive)"):
envelope = safe(
build_report_data,
client,
public_id,
include_scenarios=True,
study_slug=report.get("name", ""),
)
if envelope and envelope.get("scenarios"):
charts.scenario_bars(envelope["scenarios"])
rows = [
{
"Scenario": k,
"Benefits PV": float(v.get("total_benefits_pv") or 0),
"Costs PV": float(v.get("total_costs_pv") or 0),
"NPV": float(v.get("npv") or 0),
"ROI %": float(v.get("roi_pct") or 0),
"Payback (months)": (
round(float(v.get("payback_months") or 0), 1)
if v.get("payback_months") is not None
else None
),
}
for k, v in envelope["scenarios"].items()
]
st.dataframe(rows, use_container_width=True, hide_index=True)
# Export button
st.divider()
if st.button("📦 Build export envelope (JSON)"):
envelope = safe(
build_report_data,
client,
public_id,
include_scenarios=True,
study_slug=report.get("name", ""),
)
if envelope:
import json
data = json.dumps(envelope, indent=2, default=str)
st.download_button(
"Download export.json",
data=data,
file_name=f"{public_id}_export.json",
mime="application/json",
)

117
app/pages/versions.py Normal file
View File

@@ -0,0 +1,117 @@
"""Version history tab — list, diff, save, restore."""
from __future__ import annotations
import streamlit as st
from app.pages._helpers import safe
from core.tei_client import TEIClient
def _flatten_values(values: list[dict]) -> dict[str, dict]:
"""Index a values list by field_key for easy diffing."""
return {v.get("field_key", ""): v for v in values}
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] = []
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 {}
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)
b_val = float(by.get(y) or 0)
if abs(a_val - b_val) < 1e-9:
continue
rows.append(
{
"field_key": k,
"year": y,
"left": a_val,
"right": b_val,
"delta": b_val - a_val,
}
)
return rows
def render(client: TEIClient, tool: dict) -> None:
st.header("🕒 Versions")
public_id = tool["id"]
versions = safe(client.list_versions, public_id) or []
versions = sorted(
versions, key=lambda v: int(v.get("version_number") or 0), reverse=True
)
# Save new version
with st.expander(" Save current state as a new version", expanded=not versions):
note = st.text_area(
"Version note",
placeholder=(
"What changed? E.g. 'CFO confirmed 1.8M contacts/month; "
"raised legacy license cost from $160 to $180/agent.'"
),
)
if st.button("💾 Save version", disabled=not note.strip()):
result = safe(client.save_version, public_id, note.strip())
if result:
st.success(
f"Saved version {result.get('version_number', '?')}."
)
st.rerun()
if not versions:
st.info("No versions saved yet.")
return
# Listing
st.subheader("History")
rows = []
for v in versions:
snap = v.get("summary_snapshot") or v.get("summary") or {}
rows.append(
{
"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),
"Note": v.get("note", ""),
}
)
st.dataframe(rows, use_container_width=True, hide_index=True)
# Compare two versions
st.subheader("Compare")
if len(versions) < 2:
st.caption("Save two or more versions to compare.")
return
labels = {f"v{v['version_number']}{v.get('note', '')[:40]}": v for v in versions}
keys = list(labels.keys())
c1, c2 = st.columns(2)
with c1:
left_label = st.selectbox("Left (older)", keys, index=min(1, len(keys) - 1))
with c2:
right_label = st.selectbox("Right (newer)", keys, index=0)
if left_label == right_label:
st.caption("Pick two different versions to see a diff.")
return
left = safe(client.get_version, public_id, labels[left_label]["version_number"])
right = safe(client.get_version, public_id, labels[right_label]["version_number"])
if not (left and right):
return
a_values = left.get("values_snapshot") or left.get("values") or []
b_values = right.get("values_snapshot") or right.get("values") or []
diff = _diff_rows(_flatten_values(a_values), _flatten_values(b_values))
if not diff:
st.success("No value differences between these versions.")
else:
st.dataframe(diff, use_container_width=True, hide_index=True)

3
core/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Palladium core — shared TEI client, calculations, export, helpers."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,31 @@
"""Pure-python TEI financial math."""
from core.calculations.npv import (
discount_factor,
npv,
present_value,
present_value_series,
)
from core.calculations.payback import payback_months, payback_years
from core.calculations.roi import roi, roi_percentage
from core.calculations.scenarios import (
SCENARIOS,
apply_scenario,
risk_adjust_benefit,
risk_adjust_cost,
)
__all__ = [
"SCENARIOS",
"apply_scenario",
"discount_factor",
"npv",
"payback_months",
"payback_years",
"present_value",
"present_value_series",
"risk_adjust_benefit",
"risk_adjust_cost",
"roi",
"roi_percentage",
]

78
core/calculations/npv.py Normal file
View File

@@ -0,0 +1,78 @@
"""
Net Present Value and discounting.
Convention (matches the Forrester TEI methodology used in the Amazon Connect
study):
* The *Initial* investment (column "Initial" in TEI tables) is **not**
discounted — it occurs at time zero.
* Year-N cash flows are discounted at the end of the year:
``PV = CF_n / (1 + r) ** n`` for ``n = 1, 2, …``.
That matches the PDF note: *"The initial investment column contains costs
incurred at 'time 0' or at the beginning of Year 1 that are not discounted.
All other cash flows are discounted using the discount rate at the end of
the year."*
"""
from __future__ import annotations
from collections.abc import Iterable
def discount_factor(year: int, discount_rate: float) -> float:
"""Return ``1 / (1 + r) ** year``. Year 0 → 1.0 (no discount)."""
if year < 0:
raise ValueError("year must be >= 0")
return 1.0 / ((1.0 + discount_rate) ** year)
def present_value(amount: float, year: int, discount_rate: float) -> float:
"""Discount ``amount`` from end-of-year ``year`` to present."""
return amount * discount_factor(year, discount_rate)
def present_value_series(
cashflows: Iterable[float],
discount_rate: float,
start_year: int = 1,
) -> float:
"""
Sum the present value of a stream of year-end cashflows.
Args:
cashflows: iterable of year-N amounts (Y1, Y2, …).
discount_rate: e.g. 0.10 for 10%.
start_year: year of the first element. Default 1 (skip year-0).
"""
total = 0.0
for offset, cf in enumerate(cashflows):
total += present_value(cf, start_year + offset, discount_rate)
return total
def npv(
cashflows: Iterable[float],
discount_rate: float,
initial: float = 0.0,
) -> float:
"""
Net Present Value.
Args:
cashflows: year-end cashflows for Year 1, Year 2, …
discount_rate: e.g. 0.10
initial: undiscounted year-0 cashflow (negative for a cost,
positive for a one-off benefit).
Returns:
``initial + Σ CF_n / (1 + r)^n`` for ``n = 1..len(cashflows)``.
Example::
>>> # Amazon Connect total benefits PV ≈ $101.7M
>>> benefits = [27_279_019, 40_333_658, 57_983_794]
>>> round(npv(benefits, 0.10) / 1_000_000, 1)
101.7
"""
return initial + present_value_series(cashflows, discount_rate, start_year=1)

View File

@@ -0,0 +1,63 @@
"""
Payback period.
Linear interpolation within the crossing year: returns the moment when the
running net cashflow first turns non-negative.
Inputs are *risk-adjusted, undiscounted* cashflows by convention (TEI shows
"<6 months" payback for the Amazon Connect composite using the risk-adjusted
nominal cashflows from the Cash Flow Analysis table).
"""
from __future__ import annotations
from collections.abc import Iterable
def payback_years(
initial_cost: float,
yearly_net_benefits: Iterable[float],
) -> float | None:
"""
Years until cumulative net benefits cover the initial cost.
Args:
initial_cost: positive number — undiscounted year-0 outlay.
yearly_net_benefits: sequence of (benefits costs) per year, Y1+.
Returns:
Float number of years, or ``None`` if payback is never reached
within the supplied horizon.
Example::
>>> # Amazon Connect: initial cost $1.196M, Y1 net $19.998M
>>> # → ~0.06 years ≈ 0.7 months — well under 6 months.
>>> round(payback_years(1_196_250, [19_997_953, 31_562_489, 47_443_905]), 3)
0.06
"""
remaining = float(initial_cost)
if remaining <= 0:
return 0.0
cumulative_year = 0
for cf in yearly_net_benefits:
cumulative_year += 1
cf = float(cf)
if cf <= 0:
remaining += -cf # net loss this year increases the gap
continue
if cf >= remaining:
# Crossing happens partway through this year.
fraction = remaining / cf
return (cumulative_year - 1) + fraction
remaining -= cf
return None
def payback_months(
initial_cost: float,
yearly_net_benefits: Iterable[float],
) -> float | None:
"""Same as :func:`payback_years`, expressed in months."""
yrs = payback_years(initial_cost, yearly_net_benefits)
return yrs * 12.0 if yrs is not None else None

27
core/calculations/roi.py Normal file
View File

@@ -0,0 +1,27 @@
"""Return on Investment."""
from __future__ import annotations
def roi(benefits_pv: float, costs_pv: float) -> float:
"""
Return on Investment as a fraction.
``ROI = (Benefits Costs) / Costs``
Costs here are expressed as a positive present-value amount (the absolute
cost). Returns ``0.0`` when costs are zero (rather than dividing by zero).
Example::
>>> round(roi(101_696_791, 22_983_076), 2) # Amazon Connect: 342%
3.42
"""
if costs_pv <= 0:
return 0.0
return (benefits_pv - costs_pv) / costs_pv
def roi_percentage(benefits_pv: float, costs_pv: float) -> float:
"""ROI as a percentage (e.g. 342.0 for 342%)."""
return roi(benefits_pv, costs_pv) * 100.0

View File

@@ -0,0 +1,128 @@
"""
Scenario modelling and risk adjustment.
Forrester TEI applies a *downward* risk adjustment to benefits (subtract
``risk_factor × benefit``) and an *upward* adjustment to costs (add
``risk_factor × cost``). Scenarios scale both adoption (cashflow magnitude)
and uncertainty (risk factor).
Default scenario multipliers are sensible starting points. Override per
study by editing the study's ``config.py`` and passing a custom dict to
:func:`apply_scenario`.
"""
from __future__ import annotations
from collections.abc import Iterable
from copy import deepcopy
from typing import Any
#: Default scenario multipliers used by Palladium notebooks.
#:
#: * ``adoption`` scales nominal annual values up or down.
#: * ``risk_delta`` is *added* to the benefit's risk_adjustment factor and
#: *subtracted* from the cost's risk_adjustment factor (conservative
#: = more uncertainty on benefits, less padding on costs).
SCENARIOS: dict[str, dict[str, float]] = {
"conservative": {"adoption": 0.80, "risk_delta": 0.10},
"moderate": {"adoption": 1.00, "risk_delta": 0.00},
"aggressive": {"adoption": 1.15, "risk_delta": -0.05},
}
def risk_adjust_benefit(amount: float, risk_factor: float) -> float:
"""
Apply a downward risk adjustment to a benefit.
``adjusted = amount × (1 risk_factor)``.
``risk_factor`` is clamped to ``[0, 1]``.
"""
rf = max(0.0, min(1.0, float(risk_factor)))
return amount * (1.0 - rf)
def risk_adjust_cost(amount: float, risk_factor: float) -> float:
"""
Apply an upward risk adjustment to a cost.
``adjusted = amount × (1 + risk_factor)``.
``risk_factor`` is clamped to ``[0, 1]``.
"""
rf = max(0.0, min(1.0, float(risk_factor)))
return amount * (1.0 + rf)
def _scale_yearly(values: Iterable[float], factor: float) -> list[float]:
return [float(v) * factor for v in values]
def apply_scenario(
items: list[dict],
scenario: str = "moderate",
*,
multipliers: dict[str, dict[str, float]] | None = None,
table: str | None = None,
) -> list[dict]:
"""
Return a deep-copied list of value-rows with scenario adjustments applied.
Each item is expected to have:
- ``table`` (``'benefits'`` or ``'costs'``) — required for sign of
risk_delta. If absent, defaults to ``benefits`` unless ``table=``
is passed explicitly.
- ``year_values`` (dict of year-string → float) **or** a scalar
``value``.
- ``risk_adjustment`` (optional float).
- ``initial`` (optional, costs only) — scaled by adoption.
Args:
items: rows shaped like the ``_normalize_value`` output of
:class:`core.tei_client.TEIClient`.
scenario: key into ``multipliers`` (default ``SCENARIOS``).
multipliers: override map. Same shape as ``SCENARIOS``.
table: force a table when items lack one.
Returns:
A new list — original items are not mutated.
"""
cfg = (multipliers or SCENARIOS).get(scenario)
if cfg is None:
raise KeyError(f"Unknown scenario: {scenario!r}")
adoption = float(cfg.get("adoption", 1.0))
risk_delta = float(cfg.get("risk_delta", 0.0))
out: list[dict] = []
for raw in items:
item: dict[str, Any] = deepcopy(raw)
item_table = item.get("table") or table or "benefits"
item["table"] = item_table
# Adoption scaling
if "year_values" in item and isinstance(item["year_values"], dict):
item["year_values"] = {
k: float(v) * adoption for k, v in item["year_values"].items()
}
if "value" in item and item["value"] is not None:
try:
item["value"] = float(item["value"]) * adoption
except (TypeError, ValueError):
pass
if "initial" in item and item["initial"] is not None:
try:
item["initial"] = float(item["initial"]) * adoption
except (TypeError, ValueError):
pass
# Risk adjustment delta
ra = item.get("risk_adjustment")
if ra is None:
ra = 0.0
if item_table == "benefits":
new_ra = float(ra) + risk_delta
else: # costs: adverse scenario should *raise* costs less, so subtract
new_ra = float(ra) - risk_delta
item["risk_adjustment"] = max(0.0, min(1.0, new_ra))
out.append(item)
return out

1
core/cli/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Palladium CLI package — invoked via ``python -m palladium``."""

229
core/cli/main.py Normal file
View File

@@ -0,0 +1,229 @@
"""
Palladium CLI implementation.
Subcommands::
palladium test # Verify Athena connectivity
palladium list # List TEI tool instances
palladium reports # List TEI report templates
palladium summary <public_id> # Print a tool's financial summary
palladium calculate <public_id> # Trigger /calculate
palladium export <public_id> -o file # Save export envelope as JSON
The CLI is invoked via ``python -m palladium`` (root shim) which calls
:func:`main` here.
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
from collections.abc import Sequence
from core.export import build_report_data, write_report_data
from core.tei_client import AthenaAPIError, TEIClient
def _configure_logging(verbosity: int) -> None:
level = logging.WARNING
if verbosity == 1:
level = logging.INFO
elif verbosity >= 2:
level = logging.DEBUG
logging.basicConfig(
level=level, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="palladium",
description="Palladium — TEI Calculator CLI for Athena.",
)
p.add_argument("-v", "--verbose", action="count", default=0)
sub = p.add_subparsers(dest="command", required=True)
sub.add_parser("test", help="Verify Athena API connectivity")
sub.add_parser("list", help="List TEI tool instances")
sub.add_parser("reports", help="List TEI report templates")
s_summary = sub.add_parser("summary", help="Print a tool's financial summary")
s_summary.add_argument("public_id")
s_calc = sub.add_parser("calculate", help="Trigger calculation for a tool")
s_calc.add_argument("public_id")
s_export = sub.add_parser("export", help="Export a tool's report data as JSON")
s_export.add_argument("public_id")
s_export.add_argument(
"-o",
"--output",
default="-",
help="Output path (default: stdout)",
)
s_export.add_argument(
"--no-scenarios",
action="store_true",
help="Skip computing conservative/moderate/aggressive scenarios.",
)
s_export.add_argument(
"--study-slug",
default=None,
help="Optional study identifier to embed in metadata.",
)
return p
def _print_table(rows: list[dict], columns: list[tuple[str, str]]) -> None:
"""Tiny pure-stdlib pretty-printer."""
widths = [len(label) for _, label in columns]
formatted: list[list[str]] = []
for r in rows:
rec = [str(r.get(key, "") or "") for key, _ in columns]
formatted.append(rec)
for i, val in enumerate(rec):
if len(val) > widths[i]:
widths[i] = len(val)
fmt = " ".join(f"{{:<{w}}}" for w in widths)
print(fmt.format(*[label for _, label in columns]))
print(fmt.format(*["-" * w for w in widths]))
for rec in formatted:
print(fmt.format(*rec))
def cmd_test(client: TEIClient, args) -> int:
result = client.test_connection()
print(json.dumps(result, indent=2))
return 0 if result.get("status") == "ok" else 1
def cmd_list(client: TEIClient, args) -> int:
tools = client.list_tools()
if not tools:
print("(no TEI tools)")
return 0
rows = []
for t in tools:
report = t.get("report")
if isinstance(report, dict):
report_name = report.get("name", "")
else:
report_name = report or ""
rows.append(
{
"id": t.get("id", ""),
"name": t.get("name", ""),
"report": report_name,
"status": t.get("status", ""),
"version": t.get("current_version", ""),
"modified": t.get("modified_date", ""),
}
)
_print_table(
rows,
[
("id", "PUBLIC_ID"),
("name", "NAME"),
("report", "REPORT"),
("status", "STATUS"),
("version", "VER"),
("modified", "MODIFIED"),
],
)
return 0
def cmd_reports(client: TEIClient, args) -> int:
reports = client.list_reports()
if not reports:
print("(no reports)")
return 0
rows = [
{
"id": r.get("id", ""),
"name": r.get("name", ""),
"vendor": r.get("vendor", ""),
"version": r.get("version", ""),
"fields": r.get("field_count", 0),
"instances": r.get("instance_count", 0),
"status": r.get("status", ""),
}
for r in reports
]
_print_table(
rows,
[
("id", "PUBLIC_ID"),
("name", "NAME"),
("vendor", "VENDOR"),
("version", "VER"),
("fields", "FIELDS"),
("instances", "TOOLS"),
("status", "STATUS"),
],
)
return 0
def cmd_summary(client: TEIClient, args) -> int:
client.print_summary(args.public_id)
return 0
def cmd_calculate(client: TEIClient, args) -> int:
client.calculate(args.public_id)
print(f"Recalculated {args.public_id}.")
client.print_summary(args.public_id)
return 0
def cmd_export(client: TEIClient, args) -> int:
envelope = build_report_data(
client,
args.public_id,
include_scenarios=not args.no_scenarios,
study_slug=args.study_slug,
)
if args.output in ("-", ""):
json.dump(envelope, sys.stdout, indent=2, default=str)
sys.stdout.write("\n")
else:
path = write_report_data(envelope, args.output)
print(f"Wrote {path}")
return 0
COMMANDS = {
"test": cmd_test,
"list": cmd_list,
"reports": cmd_reports,
"summary": cmd_summary,
"calculate": cmd_calculate,
"export": cmd_export,
}
def main(argv: Sequence[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
_configure_logging(args.verbose)
try:
client = TEIClient()
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
handler = COMMANDS[args.command]
try:
return handler(client, args)
except AthenaAPIError as e:
print(f"Athena API error {e.status_code}: {e.detail}", file=sys.stderr)
return 1
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())

5
core/export/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Export utilities — build the LLM-ready JSON envelope."""
from core.export.report_data import build_report_data, write_report_data
__all__ = ["build_report_data", "write_report_data"]

224
core/export/report_data.py Normal file
View File

@@ -0,0 +1,224 @@
"""
Build the structured JSON consumed by the report pipeline.
The Athena ``GET /tools/{public_id}/export/`` endpoint already returns most
of what we need; this module:
1. Calls the export endpoint.
2. Optionally augments it with locally computed scenario analysis
(conservative / moderate / aggressive).
3. Stamps Palladium metadata (export timestamp, study slug, generator).
4. Serializes to a stable JSON file that html2docx / Peitho can consume.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from core import __version__
from core.calculations import (
apply_scenario,
npv,
payback_months,
risk_adjust_benefit,
risk_adjust_cost,
roi_percentage,
)
def _split_by_table(values: list[dict]) -> tuple[list[dict], list[dict]]:
benefits = [v for v in values if v.get("table") == "benefits"]
costs = [v for v in values if v.get("table") == "costs"]
return benefits, costs
def _yearly_totals(items: list[dict], analysis_years: int) -> list[float]:
totals = [0.0] * analysis_years
for it in items:
yv = it.get("year_values") or {}
for k, v in yv.items():
try:
year = int(k)
except (TypeError, ValueError):
continue
if 1 <= year <= analysis_years:
totals[year - 1] += float(v or 0)
return totals
def _initial_total(items: list[dict]) -> float:
return sum(float(it.get("initial") or 0) for it in items)
def _risk_adjusted(items: list[dict], for_table: str) -> list[dict]:
out: list[dict] = []
for it in items:
rf = float(it.get("risk_adjustment") or 0.0)
adj_year_values: dict[str, float] = {}
for k, v in (it.get("year_values") or {}).items():
v = float(v or 0)
adj_year_values[str(k)] = (
risk_adjust_benefit(v, rf)
if for_table == "benefits"
else risk_adjust_cost(v, rf)
)
adj = dict(it)
adj["year_values"] = adj_year_values
if "initial" in adj and adj["initial"] is not None:
adj["initial"] = (
risk_adjust_cost(float(adj["initial"]), rf)
if for_table == "costs"
else float(adj["initial"])
)
out.append(adj)
return out
def _compute_summary(
benefits: list[dict],
costs: list[dict],
discount_rate: float,
analysis_years: int,
) -> dict[str, Any]:
benefits_ra = _risk_adjusted(benefits, "benefits")
costs_ra = _risk_adjusted(costs, "costs")
benefits_yr = _yearly_totals(benefits_ra, analysis_years)
costs_yr = _yearly_totals(costs_ra, analysis_years)
initial_costs = _initial_total(costs_ra)
benefits_pv = npv(benefits_yr, discount_rate)
costs_pv = npv(costs_yr, discount_rate, initial=initial_costs)
nominal_benefits = sum(benefits_yr)
nominal_costs = sum(costs_yr) + initial_costs
net_yearly = [b - c for b, c in zip(benefits_yr, costs_yr, strict=False)]
pb = payback_months(initial_costs, net_yearly)
return {
"discount_rate": discount_rate,
"analysis_years": analysis_years,
"total_benefits_nominal": nominal_benefits,
"total_benefits_pv": benefits_pv,
"total_costs_nominal": nominal_costs,
"total_costs_pv": costs_pv,
"npv": benefits_pv - costs_pv,
"roi_pct": roi_percentage(benefits_pv, costs_pv),
"payback_months": pb,
"yearly_breakdown": [
{
"year": idx + 1,
"benefits": benefits_yr[idx],
"costs": costs_yr[idx],
"net": net_yearly[idx],
"cumulative_net": sum(net_yearly[: idx + 1]) - initial_costs,
}
for idx in range(analysis_years)
],
"initial_costs": initial_costs,
}
def build_report_data(
client,
public_id: str,
*,
include_scenarios: bool = True,
study_slug: str | None = None,
) -> dict[str, Any]:
"""
Build the full export envelope for a TEI tool.
Args:
client: a :class:`core.tei_client.TEIClient` instance.
public_id: the TEI tool's public_id.
include_scenarios: if True, locally compute conservative / moderate /
aggressive summaries and attach them under ``scenarios``.
study_slug: optional human-friendly study identifier (e.g.
``"202602_AmazonConnect"``) — written into ``metadata``.
Returns:
A dict with keys::
{
"metadata": {...}, # client / opportunity / study / generator
"report": {...}, # report template echo
"values": {benefits, costs},
"summary": {...}, # locally recomputed (mirrors Athena)
"athena_export": {...}, # raw payload from Athena (if available)
"scenarios": {...} # optional
}
"""
# Pull everything we need
bundle = client.get_tool_with_data(public_id)
tool = bundle["tool"]
fields = bundle["fields"]
values = bundle["values"]
report_obj = tool.get("report")
if isinstance(report_obj, str):
report = client.get_report(report_obj)
elif isinstance(report_obj, dict):
report = report_obj
else:
report = {}
discount_rate = float(report.get("discount_rate") or 0.10)
analysis_years = int(report.get("analysis_period_years") or 3)
benefits, costs = _split_by_table(values)
summary = _compute_summary(benefits, costs, discount_rate, analysis_years)
try:
athena_export = client.export(public_id)
except Exception as e: # pragma: no cover best effort
athena_export = {"error": str(e)}
envelope: dict[str, Any] = {
"metadata": {
"study_slug": study_slug or "",
"tool_public_id": public_id,
"tool_name": tool.get("name", ""),
"report_name": report.get("name", ""),
"report_vendor": report.get("vendor", ""),
"report_version": report.get("version", ""),
"report_public_id": report.get("id", ""),
"proposal": tool.get("proposal") or tool.get("opportunity"),
"engagement": tool.get("engagement"),
"generated_at": datetime.now(UTC).isoformat(),
"generator": f"palladium core {__version__}",
},
"report": report,
"fields": fields,
"values": {"benefits": benefits, "costs": costs},
"summary": summary,
"athena_export": athena_export,
}
if include_scenarios:
scenario_results: dict[str, Any] = {}
for scenario_name in ("conservative", "moderate", "aggressive"):
sb = apply_scenario(benefits, scenario_name, table="benefits")
sc = apply_scenario(costs, scenario_name, table="costs")
scenario_results[scenario_name] = _compute_summary(
sb, sc, discount_rate, analysis_years
)
envelope["scenarios"] = scenario_results
return envelope
def write_report_data(
envelope: dict[str, Any],
output_path: str | Path,
*,
indent: int = 2,
) -> Path:
"""Serialize ``envelope`` to ``output_path`` and return the Path."""
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(envelope, indent=indent, default=str))
return path

View File

@@ -0,0 +1,5 @@
"""Notebook helpers — pandas tables, plotly charts, IPython display."""
from core.notebook_helpers import charts, display, tables
__all__ = ["charts", "display", "tables"]

View File

@@ -0,0 +1,193 @@
"""
Plotly charts for TEI analyses.
Each function returns a ``plotly.graph_objects.Figure`` so callers can
``.show()`` (notebook), pass to ``st.plotly_chart`` (Streamlit), or write to
HTML / image. No styling is hard-coded beyond a neutral default palette.
"""
from __future__ import annotations
from collections.abc import Iterable
import plotly.graph_objects as go
PALETTE = {
"benefits": "#2E7D32", # green
"costs": "#C62828", # red
"net_positive": "#1565C0", # blue
"net_negative": "#C62828",
"cumulative": "#616161", # grey
}
def cashflow_chart(
yearly_breakdown: list[dict],
*,
title: str = "Cash Flow Analysis (Risk-Adjusted)",
initial_cost: float = 0.0,
) -> go.Figure:
"""
Stacked bars of benefits & costs by year + cumulative net line.
Mirrors the chart on page 25 of the Forrester Amazon Connect TEI study.
"""
if not yearly_breakdown:
return go.Figure(layout={"title": title})
years = ["Initial"] + [f"Year {row['year']}" for row in yearly_breakdown]
benefits = [0.0] + [float(row.get("benefits", 0)) for row in yearly_breakdown]
costs = [-float(initial_cost)] + [
-float(row.get("costs", 0)) for row in yearly_breakdown
]
# cumulative_net assumes initial cost has already been deducted
cumulative = [-float(initial_cost)] + [
float(row.get("cumulative_net", 0)) for row in yearly_breakdown
]
fig = go.Figure()
fig.add_bar(
name="Total benefits",
x=years,
y=benefits,
marker_color=PALETTE["benefits"],
)
fig.add_bar(
name="Total costs",
x=years,
y=costs,
marker_color=PALETTE["costs"],
)
fig.add_scatter(
name="Cumulative net benefits",
x=years,
y=cumulative,
mode="lines+markers",
line={"color": PALETTE["cumulative"], "width": 3},
)
fig.update_layout(
title=title,
barmode="relative",
yaxis_tickformat="$,.0f",
legend={"orientation": "h", "y": -0.15},
margin={"l": 40, "r": 20, "t": 60, "b": 40},
)
return fig
def benefits_bar(items: list[dict], *, title: str = "Benefits (Three-Year)") -> go.Figure:
"""Horizontal bars of risk-adjusted three-year totals per benefit."""
labels: list[str] = []
totals: list[float] = []
for it in items:
rf = float(it.get("risk_adjustment") or 0.0)
yv = it.get("year_values") or {}
ra_total = sum(float(v or 0) * (1.0 - rf) for v in yv.values())
labels.append(it.get("label", "") or it.get("field_key", ""))
totals.append(ra_total)
fig = go.Figure(
go.Bar(
x=totals,
y=labels,
orientation="h",
marker_color=PALETTE["benefits"],
text=[f"${t/1_000_000:,.1f}M" for t in totals],
textposition="auto",
)
)
fig.update_layout(
title=title,
xaxis_tickformat="$,.0f",
yaxis={"autorange": "reversed"},
margin={"l": 40, "r": 20, "t": 60, "b": 40},
)
return fig
def cost_breakdown_pie(
items: list[dict], *, title: str = "Cost Breakdown (Three-Year, Risk-Adjusted)"
) -> go.Figure:
"""Pie chart of risk-adjusted costs by category/label."""
labels: list[str] = []
values: list[float] = []
for it in items:
rf = float(it.get("risk_adjustment") or 0.0)
yv = it.get("year_values") or {}
initial = float(it.get("initial") or 0.0)
ra_total = (
initial * (1.0 + rf)
+ sum(float(v or 0) * (1.0 + rf) for v in yv.values())
)
labels.append(it.get("label", "") or it.get("field_key", ""))
values.append(ra_total)
fig = go.Figure(go.Pie(labels=labels, values=values, hole=0.35))
fig.update_layout(title=title, margin={"l": 40, "r": 20, "t": 60, "b": 40})
return fig
def scenario_comparison(scenarios: dict) -> go.Figure:
"""Grouped bars comparing NPV and Costs PV across scenarios."""
keys: list[str] = list(scenarios.keys())
if not keys:
return go.Figure()
benefits = [float(scenarios[k].get("total_benefits_pv") or 0) for k in keys]
costs = [float(scenarios[k].get("total_costs_pv") or 0) for k in keys]
npvs = [float(scenarios[k].get("npv") or 0) for k in keys]
fig = go.Figure()
fig.add_bar(name="Benefits PV", x=keys, y=benefits, marker_color=PALETTE["benefits"])
fig.add_bar(name="Costs PV", x=keys, y=costs, marker_color=PALETTE["costs"])
fig.add_bar(name="NPV", x=keys, y=npvs, marker_color=PALETTE["net_positive"])
fig.update_layout(
title="Scenario Comparison",
barmode="group",
yaxis_tickformat="$,.0f",
legend={"orientation": "h", "y": -0.15},
)
return fig
def cumulative_benefits_chart(
yearly_breakdown: list[dict],
*,
title: str = "Cumulative Net Benefits",
) -> go.Figure:
"""Single-line cumulative net benefits trajectory."""
if not yearly_breakdown:
return go.Figure(layout={"title": title})
years = [f"Year {row['year']}" for row in yearly_breakdown]
cumulative = [float(row.get("cumulative_net", 0)) for row in yearly_breakdown]
fig = go.Figure(
go.Scatter(
x=years,
y=cumulative,
mode="lines+markers",
fill="tozeroy",
line={"color": PALETTE["net_positive"], "width": 3},
)
)
fig.update_layout(title=title, yaxis_tickformat="$,.0f")
return fig
def waterfall(values: Iterable[tuple[str, float]], *, title: str = "TEI Waterfall") -> go.Figure:
"""
Generic waterfall (pass tuples of (label, value)).
Used by 03_business_case to show: Benefits PV → Costs PV → NPV.
"""
labels, amounts = zip(*values, strict=True) if values else ([], [])
measures = ["relative"] * (len(labels) - 1) + ["total"] if labels else []
fig = go.Figure(
go.Waterfall(
x=list(labels),
y=list(amounts),
measure=measures,
text=[f"${v/1_000_000:,.1f}M" for v in amounts],
textposition="outside",
)
)
fig.update_layout(title=title, yaxis_tickformat="$,.0f")
return fig

View File

@@ -0,0 +1,141 @@
"""
IPython display helpers — KPI cards, formatted summary blocks, alerts.
Functions are notebook-safe: they fall back to plain ``print`` when running
outside Jupyter / when IPython is not available.
"""
from __future__ import annotations
from typing import Any
try: # pragma: no cover IPython is a soft dep
from IPython.display import HTML, display
_IPY = True
except Exception: # pragma: no cover
_IPY = False
def _money(value: Any, default: str = "") -> str:
try:
v = float(value)
except (TypeError, ValueError):
return default
if abs(v) >= 1_000_000_000:
return f"${v/1_000_000_000:,.1f}B"
if abs(v) >= 1_000_000:
return f"${v/1_000_000:,.1f}M"
if abs(v) >= 1_000:
return f"${v/1_000:,.1f}K"
return f"${v:,.0f}"
def _pct(value: Any, default: str = "") -> str:
try:
v = float(value)
except (TypeError, ValueError):
return default
return f"{v:,.0f}%"
def _months(value: Any, default: str = "N/A") -> str:
if value is None:
return default
try:
v = float(value)
except (TypeError, ValueError):
return default
if v < 6:
return f"<6 months ({v:.1f})"
return f"{v:.1f} months"
def kpi_cards(summary: dict, *, title: str | None = None) -> Any:
"""
Render a row of KPI cards (NPV, ROI, Payback, Benefits PV).
In notebooks, returns/displays inline HTML. Outside IPython, prints a
plain text version.
"""
npv = _money(summary.get("npv"))
roi = _pct(summary.get("roi") or summary.get("roi_pct"))
payback = _months(summary.get("payback_months"))
benefits_pv = _money(summary.get("total_benefits_pv"))
costs_pv = _money(summary.get("total_costs_pv"))
if not _IPY: # pragma: no cover
print(title or "TEI Summary")
print(f" NPV: {npv} ROI: {roi} Payback: {payback}")
print(f" Benefits PV: {benefits_pv} Costs PV: {costs_pv}")
return None
title_html = (
f'<div style="font-size:1.1em;font-weight:600;margin-bottom:6px;color:#444;">'
f"{title}</div>"
if title
else ""
)
card_style = (
"flex:1;min-width:140px;padding:14px 18px;margin:4px;border-radius:8px;"
"background:#f7f9fc;border:1px solid #e3e8ee;"
)
label_style = "font-size:0.78em;color:#6b7480;text-transform:uppercase;letter-spacing:0.04em;"
value_style = "font-size:1.6em;font-weight:600;color:#1a2540;margin-top:4px;"
cards = [
("NPV", npv),
("ROI", roi),
("Payback", payback),
("Benefits PV", benefits_pv),
("Costs PV", costs_pv),
]
cards_html = "".join(
f'<div style="{card_style}">'
f'<div style="{label_style}">{label}</div>'
f'<div style="{value_style}">{value}</div>'
f"</div>"
for label, value in cards
)
html = (
f'<div>{title_html}'
f'<div style="display:flex;flex-wrap:wrap;align-items:stretch;">{cards_html}</div>'
f"</div>"
)
return display(HTML(html))
def summary_panel(summary: dict, *, title: str = "TEI Financial Summary") -> None:
"""Plain-text bordered summary block (mirrors the PDF Cash Flow Analysis)."""
width = 60
print("" * width)
print(f" {title}")
print("" * width)
print(f" Benefits PV : {_money(summary.get('total_benefits_pv')):>20}")
print(f" Costs PV : {_money(summary.get('total_costs_pv')):>20}")
print("" * width)
print(f" NPV : {_money(summary.get('npv')):>20}")
roi_val = summary.get("roi") or summary.get("roi_pct")
print(f" ROI : {_pct(roi_val):>20}")
print(f" Payback : {_months(summary.get('payback_months')):>20}")
print("" * width)
def alert(text: str, kind: str = "info") -> Any:
"""Coloured alert box for notebooks ('info', 'success', 'warning', 'error')."""
colors = {
"info": ("#0277bd", "#e1f5fe"),
"success": ("#2e7d32", "#e8f5e9"),
"warning": ("#ef6c00", "#fff3e0"),
"error": ("#c62828", "#ffebee"),
}
fg, bg = colors.get(kind, colors["info"])
if not _IPY: # pragma: no cover
print(f"[{kind.upper()}] {text}")
return None
html = (
f'<div style="padding:10px 14px;border-left:4px solid {fg};'
f'background:{bg};color:#1a1a1a;border-radius:4px;margin:6px 0;">'
f"{text}</div>"
)
return display(HTML(html))

View File

@@ -0,0 +1,127 @@
"""
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
nicely-formatted DataFrame for display in notebooks.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
import pandas as pd
from core.calculations import risk_adjust_benefit, risk_adjust_cost
def _years_in_data(items: Iterable[dict]) -> list[int]:
years: set[int] = set()
for it in items:
for k in (it.get("year_values") or {}):
try:
years.add(int(k))
except (TypeError, ValueError):
continue
return sorted(years)
def benefits_table(items: list[dict]) -> pd.DataFrame:
"""Tidy benefits dataframe with one row per benefit, year columns, totals."""
if not items:
return pd.DataFrame(
columns=["field_key", "label", "category", "risk_adjustment"]
)
years = _years_in_data(items)
rows: list[dict[str, Any]] = []
for it in items:
rf = float(it.get("risk_adjustment") or 0.0)
yv = it.get("year_values") or {}
row = {
"field_key": it.get("field_key", ""),
"label": it.get("label", "") or it.get("field_key", ""),
"category": it.get("category", ""),
"risk_adjustment": rf,
}
nominal_total = 0.0
ra_total = 0.0
for y in years:
v = float(yv.get(str(y)) or 0.0)
ra = risk_adjust_benefit(v, rf)
row[f"Year {y}"] = v
row[f"Year {y} (RA)"] = ra
nominal_total += v
ra_total += ra
row["Total"] = nominal_total
row["Total (RA)"] = ra_total
rows.append(row)
return pd.DataFrame(rows)
def costs_table(items: list[dict]) -> pd.DataFrame:
"""Tidy costs dataframe — adds an Initial column when present."""
if not items:
return pd.DataFrame(
columns=["field_key", "label", "category", "risk_adjustment", "Initial"]
)
years = _years_in_data(items)
rows: list[dict[str, Any]] = []
for it in items:
rf = float(it.get("risk_adjustment") or 0.0)
yv = it.get("year_values") or {}
initial = float(it.get("initial") or 0.0)
row = {
"field_key": it.get("field_key", ""),
"label": it.get("label", "") or it.get("field_key", ""),
"category": it.get("category", ""),
"risk_adjustment": rf,
"Initial": initial,
"Initial (RA)": risk_adjust_cost(initial, rf),
}
nominal_total = initial
ra_total = risk_adjust_cost(initial, rf)
for y in years:
v = float(yv.get(str(y)) or 0.0)
ra = risk_adjust_cost(v, rf)
row[f"Year {y}"] = v
row[f"Year {y} (RA)"] = ra
nominal_total += v
ra_total += ra
row["Total"] = nominal_total
row["Total (RA)"] = ra_total
rows.append(row)
return pd.DataFrame(rows)
def summary_table(summary: dict) -> pd.DataFrame:
"""Single-row summary dataframe of headline KPIs."""
pb = summary.get("payback_months")
pb_str = f"{float(pb):.1f} months" if pb not in (None, "") else "N/A"
data = {
"NPV": [float(summary.get("npv") or 0)],
"ROI %": [float(summary.get("roi") or summary.get("roi_pct") or 0)],
"Payback": [pb_str],
"Benefits PV": [float(summary.get("total_benefits_pv") or 0)],
"Costs PV": [float(summary.get("total_costs_pv") or 0)],
"Discount rate": [float(summary.get("discount_rate") or 0)],
"Analysis years": [int(summary.get("analysis_years") or 0)],
}
return pd.DataFrame(data)
def cashflow_table(summary: dict) -> pd.DataFrame:
"""Per-year cashflow dataframe from a summary's ``yearly_breakdown``."""
yb = summary.get("yearly_breakdown") or []
if not yb:
return pd.DataFrame(columns=["Year", "Benefits", "Costs", "Net", "Cumulative"])
df = pd.DataFrame(yb)
rename = {
"year": "Year",
"benefits": "Benefits",
"costs": "Costs",
"net": "Net",
"cumulative_net": "Cumulative",
}
df = df.rename(columns=rename)
return df

View File

@@ -0,0 +1,13 @@
"""TEI Client — Athena API wrapper for Palladium."""
from core.tei_client.client import AthenaAPIError, TEIClient
from core.tei_client.models import TEIField, TEIReport, TEISummary, TEIValue
__all__ = [
"AthenaAPIError",
"TEIClient",
"TEIField",
"TEIReport",
"TEISummary",
"TEIValue",
]

563
core/tei_client/client.py Normal file
View File

@@ -0,0 +1,563 @@
"""
TEI Client — Athena API wrapper for Palladium.
Endpoints (per Athena API.yaml, all under ``/api/v1/tei/``):
Reports (templates)
GET /reports/ list_reports
GET /reports/{public_id}/ get_report
GET /reports/{public_id}/fields/ list_fields
PATCH /reports/{public_id}/fields/reorder/ reorder_fields
Tools (instances)
GET /tools/ list_tools
POST /tools/ create_tool
GET /tools/{public_id}/ get_tool
PATCH /tools/{public_id}/ update_tool
DELETE /tools/{public_id}/ delete_tool
Values (data entry)
GET /tools/{public_id}/values/ get_values
PUT /tools/{public_id}/values/ update_values (bulk)
PATCH /tools/{public_id}/values/{field_key}/ patch_value (single)
Calculation & summary
POST /tools/{public_id}/calculate/ calculate
GET /tools/{public_id}/summary/ get_summary
GET /summary/ aggregate_summary
Versions
GET /tools/{public_id}/versions/ list_versions
POST /tools/{public_id}/versions/ save_version
GET /tools/{public_id}/versions/{n}/ get_version
Export
GET /tools/{public_id}/export/ export
Authentication uses the ``Authorization: Api-Key {key}`` header.
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from typing import Any
import requests
from dotenv import load_dotenv
load_dotenv()
logger = logging.getLogger(__name__)
API_PREFIX = "/api/v1/tei"
class AthenaAPIError(Exception):
"""Raised when Athena returns a non-success response."""
def __init__(self, status_code: int, detail: str, url: str):
self.status_code = status_code
self.detail = detail
self.url = url
super().__init__(f"Athena API {status_code} at {url}: {detail}")
class TEIClient:
"""
Client for Athena's TEI Calculator API.
Wraps every TEI endpoint and provides a few convenience helpers used by
the Palladium notebooks and Streamlit app.
Environment variables (read via python-dotenv):
ATHENA_BASE_URL e.g. https://athena.nttdata.com
ATHENA_API_KEY Api-Key value (admin-issued)
Example::
from core.tei_client import TEIClient
client = TEIClient()
client.test_connection()
for r in client.list_reports():
print(r["name"])
"""
def __init__(
self,
base_url: str | None = None,
api_key: str | None = None,
timeout: int = 30,
):
self.base_url = (base_url or os.getenv("ATHENA_BASE_URL", "")).rstrip("/")
self.api_key = api_key or os.getenv("ATHENA_API_KEY", "")
self.timeout = timeout
if not self.base_url:
raise ValueError(
"ATHENA_BASE_URL is required. Set it in .env or pass base_url."
)
if not self.api_key:
raise ValueError(
"ATHENA_API_KEY is required. Set it in .env or pass api_key."
)
self.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"Api-Key {self.api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
)
logger.info("TEIClient initialised for %s", self.base_url)
# ─────────────────────────────────────────────
# Internal HTTP helpers
# ─────────────────────────────────────────────
def _url(self, path: str) -> str:
if not path.startswith("/"):
path = f"/{path}"
return f"{self.base_url}{path}"
def _request(
self,
method: str,
path: str,
params: dict | None = None,
json_data: Any | None = None,
) -> Any:
url = self._url(path)
logger.debug("%s %s", method.upper(), url)
try:
response = self.session.request(
method=method,
url=url,
params=params,
json=json_data,
timeout=self.timeout,
)
except requests.ConnectionError as e:
raise AthenaAPIError(0, f"Connection failed: {e}", url) from e
except requests.Timeout as e:
raise AthenaAPIError(408, "Request timed out", url) from e
if response.status_code >= 400:
try:
payload = response.json()
detail = payload.get("detail") or json.dumps(payload)
except (json.JSONDecodeError, ValueError, AttributeError):
detail = response.text
raise AthenaAPIError(response.status_code, detail, url)
if response.status_code == 204 or not response.content:
return {}
return response.json()
def _get(self, path: str, params: dict | None = None) -> Any:
return self._request("GET", path, params=params)
def _post(self, path: str, data: Any | None = None) -> Any:
return self._request("POST", path, json_data=data)
def _put(self, path: str, data: Any | None = None) -> Any:
return self._request("PUT", path, json_data=data)
def _patch(self, path: str, data: Any | None = None) -> Any:
return self._request("PATCH", path, json_data=data)
def _delete(self, path: str) -> Any:
return self._request("DELETE", path)
def _paginated(self, path: str, params: dict | None = None) -> list[dict]:
"""
Fetch all pages of a paginated list endpoint.
Athena uses the standard DRF page/results envelope::
{"count": N, "next": url|None, "previous": ..., "results": [...]}
"""
out: list[dict] = []
result = self._get(path, params=params)
while True:
if isinstance(result, list):
out.extend(result)
return out
if not isinstance(result, dict):
return out
out.extend(result.get("results", []) or [])
next_url = result.get("next")
if not next_url:
return out
# Follow absolute next URL
try:
result = self.session.get(next_url, timeout=self.timeout).json()
except Exception: # pragma: no cover defensive
return out
# ─────────────────────────────────────────────
# Connection test
# ─────────────────────────────────────────────
def test_connection(self) -> dict:
"""Verify API connectivity and authentication."""
try:
result = self._get(f"{API_PREFIX}/reports/")
count = (
result.get("count", len(result.get("results", [])))
if isinstance(result, dict)
else len(result)
)
return {
"status": "ok",
"base_url": self.base_url,
"authenticated": True,
"reports_found": count,
"timestamp": datetime.now().isoformat(),
}
except AthenaAPIError as e:
return {
"status": "error",
"base_url": self.base_url,
"authenticated": e.status_code != 401,
"error_code": e.status_code,
"detail": e.detail,
"timestamp": datetime.now().isoformat(),
}
# ─────────────────────────────────────────────
# Reports (templates)
# ─────────────────────────────────────────────
def list_reports(self) -> list[dict]:
"""List all TEI report templates (auto-paginated)."""
return self._paginated(f"{API_PREFIX}/reports/")
def get_report(self, public_id: str) -> dict:
"""Get a TEI report template by its public_id."""
return self._get(f"{API_PREFIX}/reports/{public_id}/")
def list_fields(
self,
report_public_id: str,
table: str | None = None,
) -> list[dict]:
"""
Get field definitions for a report.
Args:
report_public_id: The report template's public_id (12-char short UUID).
table: Optional filter — ``'benefits'`` or ``'costs'``.
Returns a list of field-definition dicts. See ``TEIField.from_dict``
for the expected shape.
"""
params = {"table": table} if table else None
rows = self._paginated(
f"{API_PREFIX}/reports/{report_public_id}/fields/", params=params
)
# Defensive — server-side filter may not be implemented; filter locally.
if table:
rows = [r for r in rows if r.get("table") == table]
rows.sort(key=lambda r: (r.get("table", ""), r.get("sort_order") or 0))
return rows
def create_field(self, report_public_id: str, field: dict) -> dict:
"""Create a new field definition under a report (admin only)."""
return self._post(
f"{API_PREFIX}/reports/{report_public_id}/fields/", data=field
)
def update_field(self, report_public_id: str, field_id: int, **changes) -> dict:
"""Patch one field definition by its integer id."""
return self._patch(
f"{API_PREFIX}/reports/{report_public_id}/fields/{field_id}/",
data=changes,
)
def delete_field(self, report_public_id: str, field_id: int) -> dict:
return self._delete(
f"{API_PREFIX}/reports/{report_public_id}/fields/{field_id}/"
)
def reorder_fields(self, report_public_id: str, field_ids: list[int]) -> dict:
"""Bulk-reorder fields. Spec: PATCH /reports/{id}/fields/reorder/."""
return self._patch(
f"{API_PREFIX}/reports/{report_public_id}/fields/reorder/",
data={"field_ids": field_ids},
)
# ─────────────────────────────────────────────
# Tools (instances)
# ─────────────────────────────────────────────
def list_tools(self) -> list[dict]:
"""List TEI tool instances owned by the current API key."""
return self._paginated(f"{API_PREFIX}/tools/")
def get_tool(self, public_id: str) -> dict:
"""Get a TEI tool instance by public_id."""
return self._get(f"{API_PREFIX}/tools/{public_id}/")
def create_tool(
self,
report_public_id: str,
proposal: int | None = None,
engagement: int | None = None,
name: str | None = None,
status: str = "draft",
) -> dict:
"""
Create a new TEI tool instance from a report template.
Athena scopes a TEI tool to a *Proposal* (which itself belongs to an
Opportunity) and/or an *Engagement*. Pass the integer PK of either or
both to link the tool.
"""
data: dict[str, Any] = {"report": report_public_id, "status": status}
if proposal is not None:
data["proposal"] = proposal
if engagement is not None:
data["engagement"] = engagement
if name:
data["name"] = name
return self._post(f"{API_PREFIX}/tools/", data=data)
def update_tool(
self,
public_id: str,
name: str | None = None,
status: str | None = None,
) -> dict:
"""Update tool metadata. Only ``name`` and ``status`` are mutable."""
data: dict[str, Any] = {}
if name is not None:
data["name"] = name
if status is not None:
data["status"] = status
return self._patch(f"{API_PREFIX}/tools/{public_id}/", data=data)
def delete_tool(self, public_id: str) -> dict:
return self._delete(f"{API_PREFIX}/tools/{public_id}/")
# ─────────────────────────────────────────────
# Values (data entry)
# ─────────────────────────────────────────────
@staticmethod
def _normalize_value(value: dict) -> dict:
"""
Normalize a value-row dict into the shape the API expects.
Accepts any of the following input forms and produces a uniform
wire-format dict::
# 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}, ...}
# non-annual scalars
{"field_key": "rate", "value": 0.10, ...}
Returns a dict like::
{"field_key": "A1",
"year_values": {"1": 100.0, "2": 200.0, "3": 300.0},
"risk_adjustment": 0.15,
"notes": ""}
"""
out: dict[str, Any] = {}
if "field_key" in value:
out["field_key"] = value["field_key"]
elif "field" in value:
out["field_key"] = value["field"]
# Collect annual year_N keys into year_values
year_values: dict[str, float] = {}
if "year_values" in value and isinstance(value["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
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
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"]
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"])
return out
def get_values(self, public_id: str) -> list[dict]:
"""Get all current field values for a TEI tool instance."""
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 []
def update_values(self, public_id: str, values: list[dict]) -> dict:
"""
Bulk-update field values. See ``_normalize_value`` for accepted shapes.
"""
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
)
# ─────────────────────────────────────────────
# Calculation & summary
# ─────────────────────────────────────────────
def calculate(self, public_id: str) -> dict:
"""Trigger server-side calculation; returns the updated summary."""
return self._post(f"{API_PREFIX}/tools/{public_id}/calculate/")
def get_summary(self, public_id: str) -> dict:
"""Return the most-recent summary (404 if never calculated)."""
return self._get(f"{API_PREFIX}/tools/{public_id}/summary/")
def aggregate_summary(self) -> dict:
"""Aggregate NPV across all tools owned by the current API key."""
return self._get(f"{API_PREFIX}/summary/")
# ─────────────────────────────────────────────
# Versions
# ─────────────────────────────────────────────
def list_versions(self, public_id: str) -> list[dict]:
"""List all saved version snapshots for a TEI tool."""
result = self._get(f"{API_PREFIX}/tools/{public_id}/versions/")
if isinstance(result, list):
return result
if isinstance(result, dict):
if "results" in result and isinstance(result["results"], list):
return result["results"]
if "versions" in result and isinstance(result["versions"], list):
return result["versions"]
return []
def save_version(self, public_id: str, note: str = "") -> dict:
"""Snapshot current values + summary as a new version."""
return self._post(
f"{API_PREFIX}/tools/{public_id}/versions/",
data={"note": note},
)
def get_version(self, public_id: str, version_number: int) -> dict:
"""Get a single version's full snapshot."""
return self._get(
f"{API_PREFIX}/tools/{public_id}/versions/{int(version_number)}/"
)
# ─────────────────────────────────────────────
# Export
# ─────────────────────────────────────────────
def export(self, public_id: str) -> dict:
"""
Return the LLM-ready export payload for the report pipeline.
The shape is determined by Athena and consumed by Peitho /
html2docx; Palladium's ``core.export.report_data`` builds on this.
"""
return self._get(f"{API_PREFIX}/tools/{public_id}/export/")
# ─────────────────────────────────────────────
# Convenience
# ─────────────────────────────────────────────
def get_benefits(self, public_id: str) -> list[dict]:
"""Return only benefit-table values (table='benefits')."""
return [v for v in self.get_values(public_id) if v.get("table") == "benefits"]
def get_costs(self, public_id: str) -> list[dict]:
"""Return only cost-table values (table='costs')."""
return [v for v in self.get_values(public_id) if v.get("table") == "costs"]
def get_tool_with_data(self, public_id: str) -> dict:
"""
Bundle a tool, its field definitions, current values, and summary.
Convenience for notebook initialisation. The summary is allowed to
404 (returned as ``None``) when the tool has never been calculated.
"""
tool = self.get_tool(public_id)
report_pid = tool.get("report")
if isinstance(report_pid, dict):
report_pid = report_pid.get("id") or report_pid.get("public_id")
fields = self.list_fields(report_pid) if report_pid else []
values = self.get_values(public_id)
try:
summary = self.get_summary(public_id)
except AthenaAPIError as e:
if e.status_code == 404:
summary = None
else:
raise
return {
"tool": tool,
"fields": fields,
"values": values,
"summary": summary,
}
# ─────────────────────────────────────────────
# Display
# ─────────────────────────────────────────────
def __repr__(self) -> str: # pragma: no cover
return f"TEIClient(base_url='{self.base_url}')"
def print_summary(self, public_id: str) -> None:
"""Pretty-print a financial summary block for notebooks/REPL."""
s = self.get_summary(public_id)
def _f(v: Any, default: float = 0.0) -> float:
try:
return float(v) if v is not None else default
except (TypeError, ValueError):
return default
print("" * 56)
print(" TEI Financial Summary")
print("" * 56)
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")
payback_str = f"{_f(payback):.1f} months" if payback is not None else "N/A"
print(f" Payback: {payback_str:>17}")
print("" * 56)

194
core/tei_client/models.py Normal file
View File

@@ -0,0 +1,194 @@
"""
Lightweight dataclasses for TEI API responses.
These are *optional* — the client returns raw dicts. Use these when you want
attribute access or IDE help in notebooks.
>>> from core.tei_client import TEIClient, TEIReport
>>> raw = TEIClient().get_report("abc123")
>>> report = TEIReport.from_dict(raw)
>>> report.discount_rate
0.10
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
def _as_float(value: Any, default: float = 0.0) -> float:
"""Coerce Decimal/str/None into float."""
if value is None or value == "":
return default
if isinstance(value, Decimal):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return default
@dataclass
class TEIReport:
"""A TEI report template (model definition)."""
id: str # public_id
name: str
vendor: str
version: str
description: str = ""
analysis_period_years: int = 3
discount_rate: float = 0.10
status: str = "active"
field_count: int = 0
instance_count: int = 0
created_at: str = ""
updated_at: str = ""
@classmethod
def from_dict(cls, data: dict) -> TEIReport:
return cls(
id=str(data.get("id", "")),
name=data.get("name", ""),
vendor=data.get("vendor", ""),
version=data.get("version", ""),
description=data.get("description", "") or "",
analysis_period_years=int(data.get("analysis_period_years") or 3),
discount_rate=_as_float(data.get("discount_rate"), 0.10),
status=data.get("status", "active"),
field_count=int(data.get("field_count") or 0),
instance_count=int(data.get("instance_count") or 0),
created_at=data.get("created_at", "") or "",
updated_at=data.get("updated_at", "") or "",
)
@dataclass
class TEIField:
"""A field definition belonging to a TEI report template."""
id: int
table: str # 'benefits' | 'costs'
field_key: str
label: str
field_type: str # currency | percentage | integer | decimal | text
description: str = ""
category: str = ""
default_value: str = ""
is_annual: bool = True
risk_adjustment: float | None = None
sort_order: int = 0
is_required: bool = False
source_notes: str = ""
@classmethod
def from_dict(cls, data: dict) -> TEIField:
ra = data.get("risk_adjustment")
return cls(
id=int(data.get("id") or 0),
table=data.get("table", "benefits"),
field_key=data.get("field_key", ""),
label=data.get("label", ""),
field_type=data.get("field_type", "decimal"),
description=data.get("description", "") or "",
category=data.get("category", "") or "",
default_value=data.get("default_value", "") or "",
is_annual=bool(data.get("is_annual", True)),
risk_adjustment=_as_float(ra) if ra is not None else None,
sort_order=int(data.get("sort_order") or 0),
is_required=bool(data.get("is_required", False)),
source_notes=data.get("source_notes", "") or "",
)
@dataclass
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:
- annual fields: {field_key, year_values: {"1": ..., "2": ...},
risk_adjustment, notes}
- non-annual scalar: {field_key, value, risk_adjustment, notes}
"""
field_key: str
year_values: dict[str, float] = field(default_factory=dict)
value: float | None = None
risk_adjustment: float | None = None
notes: str = ""
@classmethod
def from_dict(cls, data: dict) -> TEIValue:
ra = data.get("risk_adjustment")
yv_raw = data.get("year_values") or {}
year_values = {str(k): _as_float(v) for k, v in yv_raw.items()}
v = data.get("value")
return cls(
field_key=data.get("field_key", ""),
year_values=year_values,
value=_as_float(v) if v is not None else None,
risk_adjustment=_as_float(ra) if ra is not None else None,
notes=data.get("notes", "") or "",
)
def to_dict(self) -> dict:
out: dict[str, Any] = {"field_key": self.field_key}
if self.year_values:
out["year_values"] = self.year_values
if self.value is not None:
out["value"] = self.value
if self.risk_adjustment is not None:
out["risk_adjustment"] = self.risk_adjustment
if self.notes:
out["notes"] = self.notes
return out
@dataclass
class TEISummary:
"""Calculated financial summary for a TEI tool instance."""
npv: float = 0.0
roi: float = 0.0
payback_months: float | None = None
discount_rate: float = 0.10
analysis_years: int = 3
total_benefits_nominal: float = 0.0
total_benefits_risk_adjusted: float = 0.0
total_benefits_pv: float = 0.0
total_costs_nominal: float = 0.0
total_costs_risk_adjusted: float = 0.0
total_costs_pv: float = 0.0
yearly_breakdown: list[dict] = field(default_factory=list)
category_breakdown: list[dict] = field(default_factory=list)
raw: dict = field(default_factory=dict)
@classmethod
def from_dict(cls, data: dict) -> TEISummary:
return cls(
npv=_as_float(data.get("npv")),
roi=_as_float(data.get("roi")),
payback_months=(
_as_float(data.get("payback_months"))
if data.get("payback_months") is not None
else None
),
discount_rate=_as_float(data.get("discount_rate"), 0.10),
analysis_years=int(data.get("analysis_years") or 3),
total_benefits_nominal=_as_float(data.get("total_benefits_nominal")),
total_benefits_risk_adjusted=_as_float(
data.get("total_benefits_risk_adjusted")
),
total_benefits_pv=_as_float(data.get("total_benefits_pv")),
total_costs_nominal=_as_float(data.get("total_costs_nominal")),
total_costs_risk_adjusted=_as_float(data.get("total_costs_risk_adjusted")),
total_costs_pv=_as_float(data.get("total_costs_pv")),
yearly_breakdown=list(data.get("yearly_breakdown") or []),
category_breakdown=list(data.get("category_breakdown") or []),
raw=dict(data),
)

30709
docs/Athena API.yaml Normal file

File diff suppressed because it is too large Load Diff

7
palladium/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""
Palladium CLI shim — exposes ``python -m palladium``.
The actual logic lives in :mod:`core.cli.main`. This package exists so the
README's command line interface (``python -m palladium test``) works without
clashing with the top-level project name.
"""

6
palladium/__main__.py Normal file
View File

@@ -0,0 +1,6 @@
"""``python -m palladium`` entrypoint."""
from core.cli.main import main
if __name__ == "__main__":
raise SystemExit(main())

49
pyproject.toml Normal file
View File

@@ -0,0 +1,49 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "palladium"
version = "0.1.0"
description = "TEI (Total Economic Impact) Calculator — Palladium"
readme = "README.md"
requires-python = ">=3.11"
license = {file = "LICENSE"}
authors = [{name = "NTT Data"}]
dependencies = [
"requests>=2.31",
"python-dotenv>=1.0",
"pandas>=2.0",
"plotly>=5.18",
"numpy>=1.26",
]
[project.optional-dependencies]
notebooks = ["jupyter>=1.0", "ipython>=8.0"]
app = ["streamlit>=1.30"]
dev = ["pytest>=7.4", "ruff>=0.1"]
[project.scripts]
palladium = "core.cli.main:main"
[tool.setuptools.packages.find]
include = ["core*", "palladium*"]
exclude = ["tests*", "studies*", "app*", "docs*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-q"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "W"]
ignore = ["E501"] # line length handled by formatter
[tool.ruff.lint.per-file-ignores]
"studies/*/notebooks/*.ipynb" = ["E402"]
"tests/*" = ["F401"]
"app/main.py" = ["E402"] # sys.path bootstrap before app imports

9
requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
requests>=2.31
python-dotenv>=1.0
jupyter>=1.0
streamlit>=1.30
pandas>=2.0
plotly>=5.18
numpy>=1.26
pytest>=7.4
ruff>=0.1

View File

@@ -0,0 +1,71 @@
# 202602 — Amazon Connect TEI
Self-contained TEI study folder. All data, notebooks, and exports for the
Forrester *Total Economic Impact™ Of Amazon Connect* (February 2026,
commissioned by AWS) live here.
## Source
The full Forrester study is at [`docs/202602_TEI Report Amazon Connect.pdf`](docs/202602_TEI%20Report%20Amazon%20Connect.pdf).
Key composite numbers reproduced in `seed_data.py`:
| Metric | Value |
|---|---|
| ROI | **342%** |
| NPV | **$78.7M** |
| Benefits PV | $101.7M |
| Costs PV | $23.0M |
| Payback | <6 months |
| Discount rate | 10% |
| Analysis period | 3 years |
## Composite organization
* Global B2C, ~$10B revenue (Y1), 30% YoY growth
* 2,000 contact-center agents, 200 supervisors
* 20M annual contacts (75% calls, 25% chat)
* 10-min average handle time
## Layout
```
202602_AmazonConnect/
├── README.md ← this file
├── config.py ← TOOL_PUBLIC_ID, REPORT_PUBLIC_ID, study slug
├── seed_data.py ← BENEFITS, COSTS, ASSUMPTIONS as Python dicts
├── notebooks/
│ ├── 01_benefits.ipynb ← quantify the 5 benefits, push to Athena
│ ├── 02_costs.ipynb ← quantify the 3 costs
│ ├── 03_business_case.ipynb ← /calculate, charts, scenarios
│ └── 04_export.ipynb ← /export → exports/export.json
├── exports/ ← generated; .gitignored
└── docs/
└── 202602_TEI Report Amazon Connect.pdf
```
## Workflow
1. **Set up credentials** in the project root `.env` (see `.env.example`).
2. **Create / link the TEI tool** in Athena, then put its `public_id` in
[`config.py`](config.py).
3. **Open `notebooks/01_benefits.ipynb`** and run all — pushes the 5
benefit rows from `seed_data.py` into Athena.
4. **`02_costs.ipynb`** — pushes the 3 cost rows.
5. **`03_business_case.ipynb`** — calls `/calculate`, renders the cash
flow chart, runs scenario analysis. Should reproduce the PDF's
$78.7M NPV / 342% ROI.
6. **`04_export.ipynb`** — writes `exports/export.json` for the report
pipeline.
## Adding a new study
Copy this folder, rename to `YYYYMM_<Vendor><Solution>`, and:
1. Replace `seed_data.py` with your benefits/costs.
2. Update `config.py` with the new tool/report public IDs.
3. Tweak the notebooks' narrative; the helper imports are the same.
The only thing that changes between studies is the **data** and the
**narrative prose** in the notebooks. All math, charts, and API calls
come from `core/`.

View File

View File

@@ -0,0 +1,39 @@
"""
Study configuration for the Amazon Connect TEI (February 2026).
Set ``TOOL_PUBLIC_ID`` to the public_id of the live TEI tool instance in
Athena once it has been created. ``REPORT_PUBLIC_ID`` is the template
this tool was created from (Athena admin sets up Report templates).
Until both are filled in, the notebooks fall back to local-only mode:
they compute summaries from ``seed_data.py`` using ``core.calculations``
and skip the network round-trip.
"""
from __future__ import annotations
import os
#: Human-friendly study identifier — used in export metadata + filenames.
STUDY_SLUG = "202602_AmazonConnect"
#: TEI Report template public_id (12-char short UUID). Provisioned in
#: Athena admin → TEI → Reports.
REPORT_PUBLIC_ID: str = os.getenv("PALLADIUM_REPORT_PUBLIC_ID", "")
#: TEI Tool instance public_id. Created via the API
#: (``client.create_tool``) or the Streamlit app sidebar.
TOOL_PUBLIC_ID: str = os.getenv("PALLADIUM_TOOL_PUBLIC_ID", "")
#: Default discount rate used for local validation of the study numbers.
DISCOUNT_RATE = 0.10
#: Analysis horizon (years).
ANALYSIS_YEARS = 3
#: 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
)

View File

@@ -0,0 +1,269 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "231c773a",
"metadata": {},
"source": [
"# 01 — Benefits Analysis\n",
"\n",
"**Study:** Forrester *Total Economic Impact™ Of Amazon Connect* (Feb 2026)\n",
"\n",
"Quantify the five benefit categories Forrester identified for the\n",
"composite organization, push them into Athena, and verify the totals\n",
"match the published study (Benefits PV ≈ **$101.7M**)."
]
},
{
"cell_type": "markdown",
"id": "110d7e61",
"metadata": {},
"source": [
"## Setup\n",
"\n",
"We add the project root to `sys.path` so the notebook can import `core` and\n",
"the study's local modules without `pip install -e .`."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "c83c2758",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Project root: /home/robert/notebook/git/palladium\n",
"Study root: /home/robert/notebook/git/palladium/studies/202602_AmazonConnect\n"
]
}
],
"source": [
"import sys\n",
"from pathlib import Path\n",
"\n",
"ROOT = Path.cwd().resolve()\n",
"while ROOT != ROOT.parent and not (ROOT / 'core').is_dir():\n",
" ROOT = ROOT.parent\n",
"if str(ROOT) not in sys.path:\n",
" sys.path.insert(0, str(ROOT))\n",
"\n",
"STUDY = ROOT / 'studies' / '202602_AmazonConnect'\n",
"if str(STUDY) not in sys.path:\n",
" sys.path.insert(0, str(STUDY))\n",
"print(f'Project root: {ROOT}')\n",
"print(f'Study root: {STUDY}')"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "c371ef85",
"metadata": {},
"outputs": [
{
"ename": "ModuleNotFoundError",
"evalue": "No module named 'pandas'",
"output_type": "error",
"traceback": [
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
"\u001b[31mModuleNotFoundError\u001b[39m Traceback (most recent call last)",
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[2]\u001b[39m\u001b[32m, line 4\u001b[39m\n\u001b[32m 1\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m config\n\u001b[32m 2\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m seed_data\n\u001b[32m 3\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m core.calculations \u001b[38;5;28;01mimport\u001b[39;00m npv, risk_adjust_benefit\n\u001b[32m----> \u001b[39m\u001b[32m4\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m core.notebook_helpers \u001b[38;5;28;01mimport\u001b[39;00m charts, display, tables\n\u001b[32m 5\u001b[39m \n\u001b[32m 6\u001b[39m display.alert(\n\u001b[32m 7\u001b[39m f'Study: <b>{config.STUDY_SLUG}</b> • discount rate {config.DISCOUNT_RATE:.0%} '\n",
"\u001b[36mFile \u001b[39m\u001b[32m~/notebook/git/palladium/core/notebook_helpers/__init__.py:3\u001b[39m\n\u001b[32m 1\u001b[39m \u001b[33;03m\"\"\"Notebook helpers — pandas tables, plotly charts, IPython display.\"\"\"\u001b[39;00m\n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mcore\u001b[39;00m\u001b[34;01m.\u001b[39;00m\u001b[34;01mnotebook_helpers\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mimport\u001b[39;00m charts, display, tables\n\u001b[32m 5\u001b[39m __all__ = [\u001b[33m\"\u001b[39m\u001b[33mcharts\u001b[39m\u001b[33m\"\u001b[39m, \u001b[33m\"\u001b[39m\u001b[33mdisplay\u001b[39m\u001b[33m\"\u001b[39m, \u001b[33m\"\u001b[39m\u001b[33mtables\u001b[39m\u001b[33m\"\u001b[39m]\n",
"\u001b[36mFile \u001b[39m\u001b[32m~/notebook/git/palladium/core/notebook_helpers/tables.py:13\u001b[39m\n\u001b[32m 9\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01m__future__\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mimport\u001b[39;00m annotations\n\u001b[32m 11\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mtyping\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mimport\u001b[39;00m Any, Iterable\n\u001b[32m---> \u001b[39m\u001b[32m13\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mpandas\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mas\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mpd\u001b[39;00m\n\u001b[32m 15\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mcore\u001b[39;00m\u001b[34;01m.\u001b[39;00m\u001b[34;01mcalculations\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mimport\u001b[39;00m risk_adjust_benefit, risk_adjust_cost\n\u001b[32m 18\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34m_years_in_data\u001b[39m(items: Iterable[\u001b[38;5;28mdict\u001b[39m]) -> \u001b[38;5;28mlist\u001b[39m[\u001b[38;5;28mint\u001b[39m]:\n",
"\u001b[31mModuleNotFoundError\u001b[39m: No module named 'pandas'"
]
}
],
"source": [
"import config\n",
"import seed_data\n",
"from core.calculations import npv, risk_adjust_benefit\n",
"from core.notebook_helpers import charts, display, tables\n",
"\n",
"display.alert(\n",
" f'Study: <b>{config.STUDY_SLUG}</b> • discount rate {config.DISCOUNT_RATE:.0%} '\n",
" f'• {config.ANALYSIS_YEARS}-year horizon',\n",
" 'info',\n",
")"
]
},
{
"cell_type": "markdown",
"id": "fd94503d",
"metadata": {},
"source": [
"## Benefits — nominal & risk-adjusted\n",
"\n",
"Forrester quantifies five benefit categories:\n",
"\n",
"| Ref | Benefit | Y1 | Y2 | Y3 | Risk Adj |\n",
"|---|---|---|---|---|---|\n",
"| At | AI-driven contact resolution efficiency | $13.9M | $23.9M | $37.8M | 15% |\n",
"| Bt | AI-powered content & sentiment analysis | $4.6M | $5.4M | $6.3M | 15% |\n",
"| Ct | AI-enabled forecasting & supervision | $6.7M | $9.1M | $12.4M | 15% |\n",
"| Dt | Data-driven profit lift (conversion +20%) | $1.2M | $1.6M | $2.0M | 20% |\n",
"| Et | Legacy solution cost savings | $6.2M | $8.0M | $10.4M | 20% |\n",
"\n",
"All five are seeded in `seed_data.BENEFITS` with full source notes."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6177ea7c",
"metadata": {},
"outputs": [],
"source": [
"df = tables.benefits_table(seed_data.BENEFITS)\n",
"df.style.format({col: '${:,.0f}' for col in df.columns if col not in ('field_key','label','category','risk_adjustment')})"
]
},
{
"cell_type": "markdown",
"id": "573f12d8",
"metadata": {},
"source": [
"## Local validation against the PDF\n",
"\n",
"Re-derive the per-benefit risk-adjusted PV and confirm we land on Forrester's\n",
"**$101,696,791** total within rounding."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8cf32003",
"metadata": {},
"outputs": [],
"source": [
"import pandas as pd\n",
"\n",
"rows = []\n",
"for b in seed_data.BENEFITS:\n",
" rf = b['risk_adjustment']\n",
" yr = [b['year_values'][str(y)] for y in (1, 2, 3)]\n",
" yr_ra = [risk_adjust_benefit(v, rf) for v in yr]\n",
" pv = npv(yr_ra, config.DISCOUNT_RATE)\n",
" rows.append({\n",
" 'Benefit': b['label'],\n",
" 'Y1 (RA)': yr_ra[0],\n",
" 'Y2 (RA)': yr_ra[1],\n",
" 'Y3 (RA)': yr_ra[2],\n",
" 'PV': pv,\n",
" })\n",
"df_check = pd.DataFrame(rows)\n",
"df_check.loc[len(df_check)] = ['TOTAL', df_check['Y1 (RA)'].sum(), df_check['Y2 (RA)'].sum(), df_check['Y3 (RA)'].sum(), df_check['PV'].sum()]\n",
"df_check.style.format({c: '${:,.0f}' for c in df_check.columns if c != 'Benefit'})"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3ded50c8",
"metadata": {},
"outputs": [],
"source": [
"expected_pv = 101_696_791\n",
"computed_pv = df_check.iloc[-1]['PV']\n",
"delta = computed_pv - expected_pv\n",
"kind = 'success' if abs(delta) < 1_000 else 'warning'\n",
"display.alert(\n",
" f'Computed Benefits PV: <b>${computed_pv:,.0f}</b><br>'\n",
" f'Forrester target: <b>${expected_pv:,.0f}</b><br>'\n",
" f'Δ = ${delta:,.0f} (rounding)',\n",
" kind,\n",
")"
]
},
{
"cell_type": "markdown",
"id": "a5ad453a",
"metadata": {},
"source": [
"## Visualize\n",
"\n",
"Horizontal bar chart of risk-adjusted three-year totals — mirrors the PDF p.6\n",
"*Benefits (Three-Year)* graphic."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "452b8408",
"metadata": {},
"outputs": [],
"source": [
"charts.benefits_bar(seed_data.BENEFITS).show()"
]
},
{
"cell_type": "markdown",
"id": "1c4591f5",
"metadata": {},
"source": [
"## Push to Athena\n",
"\n",
"When `config.TOOL_PUBLIC_ID` is set, persist the seed values to the live\n",
"TEI tool. Otherwise this cell is a no-op so the notebook still runs\n",
"offline."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d10a54b6",
"metadata": {},
"outputs": [],
"source": [
"if config.TOOL_PUBLIC_ID:\n",
" from core.tei_client import TEIClient\n",
"\n",
" client = TEIClient()\n",
" result = client.update_values(config.TOOL_PUBLIC_ID, seed_data.BENEFITS)\n",
" display.alert(f'Pushed {len(seed_data.BENEFITS)} benefit rows to '\n",
" f'tool <code>{config.TOOL_PUBLIC_ID}</code>.', 'success')\n",
"else:\n",
" display.alert(\n",
" 'No TOOL_PUBLIC_ID set in config.py — skipped Athena push. '\n",
" 'Set <code>PALLADIUM_TOOL_PUBLIC_ID</code> in your environment '\n",
" 'or edit config.py to enable.',\n",
" 'info',\n",
" )"
]
},
{
"cell_type": "markdown",
"id": "78693c14",
"metadata": {},
"source": [
"---\n",
"\n",
"Continue with [`02_costs.ipynb`](02_costs.ipynb) →"
]
}
],
"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.13.7"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -0,0 +1,213 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "1a76b7ed",
"metadata": {},
"source": [
"# 02 — Costs Analysis\n",
"\n",
"**Study:** Forrester TEI™ Of Amazon Connect (Feb 2026)\n",
"\n",
"Three cost categories, three-year horizon, 10% discount rate.\n",
"Target risk-adjusted PV = **$22,983,076**."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "46446223",
"metadata": {},
"outputs": [],
"source": [
"import sys\n",
"from pathlib import Path\n",
"\n",
"ROOT = Path.cwd().resolve()\n",
"while ROOT != ROOT.parent and not (ROOT / 'core').is_dir():\n",
" ROOT = ROOT.parent\n",
"if str(ROOT) not in sys.path:\n",
" sys.path.insert(0, str(ROOT))\n",
"STUDY = ROOT / 'studies' / '202602_AmazonConnect'\n",
"if str(STUDY) not in sys.path:\n",
" sys.path.insert(0, str(STUDY))"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "4ec64198",
"metadata": {},
"outputs": [],
"source": [
"import config\n",
"import seed_data\n",
"from core.calculations import npv, risk_adjust_cost\n",
"from core.notebook_helpers import charts, display, tables"
]
},
{
"cell_type": "markdown",
"id": "26f1d385",
"metadata": {},
"source": [
"## Costs — nominal & risk-adjusted\n",
"\n",
"| Ref | Cost | Initial | Y1 | Y2 | Y3 | Risk Adj |\n",
"|---|---|---|---|---|---|---|\n",
"| Ft | Amazon Connect usage | — | $6.5M | $8.0M | $9.8M | ↑5% |\n",
"| Gt | Implementation & migration | $1.09M | $188K | $188K | — | ↑10% |\n",
"| Ht | Ongoing management | — | $256K | $187K | $187K | ↑15% |\n",
"\n",
"Note **costs are risk-adjusted *upward*** (higher risk → higher modelled cost)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9635f334",
"metadata": {},
"outputs": [],
"source": [
"df = tables.costs_table(seed_data.COSTS)\n",
"df.style.format({c: '${:,.0f}' for c in df.columns if c not in ('field_key','label','category','risk_adjustment')})"
]
},
{
"cell_type": "markdown",
"id": "0667d1da",
"metadata": {},
"source": [
"## Local validation\n",
"\n",
"Reproduce the **$22,983,076** Costs PV from the PDF Cash Flow Analysis."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3e35a794",
"metadata": {},
"outputs": [],
"source": [
"import pandas as pd\n",
"\n",
"rows = []\n",
"for c in seed_data.COSTS:\n",
" rf = c['risk_adjustment']\n",
" init_ra = risk_adjust_cost(c.get('initial') or 0, rf)\n",
" yr = [c['year_values'][str(y)] for y in (1, 2, 3)]\n",
" yr_ra = [risk_adjust_cost(v, rf) for v in yr]\n",
" pv = npv(yr_ra, config.DISCOUNT_RATE, initial=init_ra)\n",
" rows.append({\n",
" 'Cost': c['label'],\n",
" 'Initial (RA)': init_ra,\n",
" 'Y1 (RA)': yr_ra[0],\n",
" 'Y2 (RA)': yr_ra[1],\n",
" 'Y3 (RA)': yr_ra[2],\n",
" 'PV': pv,\n",
" })\n",
"df_check = pd.DataFrame(rows)\n",
"totals = df_check.drop(columns='Cost').sum()\n",
"df_check.loc[len(df_check)] = ['TOTAL'] + totals.tolist()\n",
"df_check.style.format({c: '${:,.0f}' for c in df_check.columns if c != 'Cost'})"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "4109784e",
"metadata": {},
"outputs": [],
"source": [
"expected_pv = 22_983_076\n",
"computed_pv = df_check.iloc[-1]['PV']\n",
"delta = computed_pv - expected_pv\n",
"kind = 'success' if abs(delta) < 1_000 else 'warning'\n",
"display.alert(\n",
" f'Computed Costs PV: <b>${computed_pv:,.0f}</b><br>'\n",
" f'Forrester target: <b>${expected_pv:,.0f}</b><br>'\n",
" f'Δ = ${delta:,.0f}',\n",
" kind,\n",
")"
]
},
{
"cell_type": "markdown",
"id": "dd1b3c04",
"metadata": {},
"source": [
"## Cost mix\n",
"\n",
"Most of the three-year cost (~90%) is Amazon Connect *usage* (Ft) —\n",
"consistent with the PDF's framing that consumption-based pricing dominates,\n",
"with implementation a one-time investment."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "90e9b5e2",
"metadata": {},
"outputs": [],
"source": [
"charts.cost_breakdown_pie(seed_data.COSTS).show()"
]
},
{
"cell_type": "markdown",
"id": "3d15ae10",
"metadata": {},
"source": [
"## Push to Athena"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "03547040",
"metadata": {},
"outputs": [],
"source": [
"if config.TOOL_PUBLIC_ID:\n",
" from core.tei_client import TEIClient\n",
"\n",
" client = TEIClient()\n",
" client.update_values(config.TOOL_PUBLIC_ID, seed_data.COSTS)\n",
" display.alert(f'Pushed {len(seed_data.COSTS)} cost rows to '\n",
" f'tool <code>{config.TOOL_PUBLIC_ID}</code>.', 'success')\n",
"else:\n",
" display.alert('No TOOL_PUBLIC_ID set — skipped Athena push.', 'info')"
]
},
{
"cell_type": "markdown",
"id": "6f5befbb",
"metadata": {},
"source": [
"Continue with [`03_business_case.ipynb`](03_business_case.ipynb) →"
]
}
],
"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.13.7"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -0,0 +1,221 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 03 — Business Case\n",
"\n",
"Combine the benefits and costs into the consolidated TEI summary,\n",
"render the Cash Flow chart, and run scenario analysis. This notebook\n",
"should reproduce the headline numbers from the PDF Financial Summary:\n",
"\n",
"* **NPV $78.7M • ROI 342% • Payback <6 months**"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import sys\n",
"from pathlib import Path\n",
"\n",
"ROOT = Path.cwd().resolve()\n",
"while ROOT != ROOT.parent and not (ROOT / 'core').is_dir():\n",
" ROOT = ROOT.parent\n",
"if str(ROOT) not in sys.path:\n",
" sys.path.insert(0, str(ROOT))\n",
"STUDY = ROOT / 'studies' / '202602_AmazonConnect'\n",
"if str(STUDY) not in sys.path:\n",
" sys.path.insert(0, str(STUDY))"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import config\n",
"import seed_data\n",
"from core.export import build_report_data\n",
"from core.export.report_data import _compute_summary\n",
"from core.notebook_helpers import charts, display, tables"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Local summary (no Athena round-trip)\n",
"\n",
"Compute the moderate-case TEI summary directly from `seed_data` so the\n",
"notebook produces results even before the Athena tool is provisioned."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"summary = _compute_summary(\n",
" seed_data.BENEFITS,\n",
" seed_data.COSTS,\n",
" config.DISCOUNT_RATE,\n",
" config.ANALYSIS_YEARS,\n",
")\n",
"# `_compute_summary` returns roi_pct; expose it as `roi` for kpi_cards.\n",
"summary['roi'] = summary.get('roi_pct')\n",
"display.kpi_cards(summary, title='Forrester composite — moderate case')"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"df_cash = tables.cashflow_table(summary)\n",
"df_cash.style.format({c: '${:,.0f}' for c in df_cash.columns if c != 'Year'})"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Cash flow chart\n",
"\n",
"Mirrors the chart on PDF page 25: stacked benefits/costs by year +\n",
"cumulative-net line."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"charts.cashflow_chart(\n",
" summary['yearly_breakdown'],\n",
" initial_cost=summary.get('initial_costs', 0),\n",
").show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Waterfall: Benefits PV → Costs PV → NPV"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"charts.waterfall([\n",
" ('Benefits PV', summary['total_benefits_pv']),\n",
" ('Costs PV', -summary['total_costs_pv']),\n",
" ('NPV', summary['npv']),\n",
"]).show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Scenario analysis\n",
"\n",
"Apply the default Palladium multipliers (see `core.calculations.SCENARIOS`):\n",
"\n",
"* **Conservative** — 80% adoption, +10pp risk on benefits / -10pp on costs\n",
"* **Moderate** — base case (= the published Forrester study)\n",
"* **Aggressive** — 115% adoption, -5pp risk on benefits / +5pp on costs"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from core.calculations import apply_scenario\n",
"import pandas as pd\n",
"\n",
"scenario_summaries = {}\n",
"for name in ('conservative', 'moderate', 'aggressive'):\n",
" sb = apply_scenario(seed_data.BENEFITS, name, table='benefits')\n",
" sc = apply_scenario(seed_data.COSTS, name, table='costs')\n",
" scenario_summaries[name] = _compute_summary(sb, sc, config.DISCOUNT_RATE, config.ANALYSIS_YEARS)\n",
"\n",
"scen_df = pd.DataFrame([\n",
" {\n",
" 'Scenario': k,\n",
" 'Benefits PV': v['total_benefits_pv'],\n",
" 'Costs PV': v['total_costs_pv'],\n",
" 'NPV': v['npv'],\n",
" 'ROI %': v['roi_pct'],\n",
" 'Payback (mo)': round(v['payback_months'], 1) if v['payback_months'] is not None else None,\n",
" }\n",
" for k, v in scenario_summaries.items()\n",
"])\n",
"scen_df.style.format({\n",
" 'Benefits PV': '${:,.0f}', 'Costs PV': '${:,.0f}', 'NPV': '${:,.0f}', 'ROI %': '{:,.0f}%'\n",
"})"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"charts.scenario_comparison(scenario_summaries).show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Cross-check vs Athena (optional)\n",
"\n",
"When `TOOL_PUBLIC_ID` is set, ask Athena to recalculate the summary on\n",
"the server side and confirm it matches our local computation."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"if config.TOOL_PUBLIC_ID:\n",
" from core.tei_client import TEIClient\n",
"\n",
" client = TEIClient()\n",
" client.calculate(config.TOOL_PUBLIC_ID)\n",
" server_summary = client.get_summary(config.TOOL_PUBLIC_ID)\n",
" display.kpi_cards(server_summary, title='Athena server-side summary')\n",
"else:\n",
" display.alert('Set TOOL_PUBLIC_ID to compare Athena vs local.', 'info')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Continue with [`04_export.ipynb`](04_export.ipynb) →"
]
}
],
"metadata": {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
"language_info": {"name": "python", "version": "3.11"}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -0,0 +1,195 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "15a4163e",
"metadata": {},
"source": [
"# 04 — Export for the report pipeline\n",
"\n",
"Build the structured JSON envelope consumed by the html2docx report\n",
"generation pipeline (Peitho). Output goes to `exports/export.json`."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "18f02ef8",
"metadata": {},
"outputs": [],
"source": [
"import sys\n",
"from pathlib import Path\n",
"\n",
"ROOT = Path.cwd().resolve()\n",
"while ROOT != ROOT.parent and not (ROOT / 'core').is_dir():\n",
" ROOT = ROOT.parent\n",
"if str(ROOT) not in sys.path:\n",
" sys.path.insert(0, str(ROOT))\n",
"STUDY = ROOT / 'studies' / '202602_AmazonConnect'\n",
"if str(STUDY) not in sys.path:\n",
" sys.path.insert(0, str(STUDY))"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7d91c01d",
"metadata": {},
"outputs": [],
"source": [
"import json\n",
"from datetime import datetime, timezone\n",
"\n",
"import config\n",
"import seed_data\n",
"from core import __version__\n",
"from core.calculations import apply_scenario\n",
"from core.export.report_data import _compute_summary\n",
"from core.notebook_helpers import display"
]
},
{
"cell_type": "markdown",
"id": "cff0b35b",
"metadata": {},
"source": [
"## Build the envelope\n",
"\n",
"Two paths:\n",
"\n",
"* **Live** — `core.export.build_report_data(client, public_id)` pulls\n",
" authoritative values + summary from Athena and stamps it.\n",
"* **Local** — when no `TOOL_PUBLIC_ID` is configured, build the envelope\n",
" directly from `seed_data` so this notebook is always runnable."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "19416ff3",
"metadata": {},
"outputs": [],
"source": [
"if config.TOOL_PUBLIC_ID:\n",
" from core.export import build_report_data\n",
" from core.tei_client import TEIClient\n",
"\n",
" client = TEIClient()\n",
" envelope = build_report_data(\n",
" client,\n",
" config.TOOL_PUBLIC_ID,\n",
" include_scenarios=True,\n",
" study_slug=config.STUDY_SLUG,\n",
" )\n",
" source = 'live (Athena)'\n",
"else:\n",
" summary = _compute_summary(\n",
" seed_data.BENEFITS, seed_data.COSTS, config.DISCOUNT_RATE, config.ANALYSIS_YEARS\n",
" )\n",
" summary['roi'] = summary.get('roi_pct')\n",
" scenarios = {}\n",
" for name in ('conservative', 'moderate', 'aggressive'):\n",
" sb = apply_scenario(seed_data.BENEFITS, name, table='benefits')\n",
" sc = apply_scenario(seed_data.COSTS, name, table='costs')\n",
" scenarios[name] = _compute_summary(sb, sc, config.DISCOUNT_RATE, config.ANALYSIS_YEARS)\n",
" envelope = {\n",
" 'metadata': {\n",
" 'study_slug': config.STUDY_SLUG,\n",
" 'tool_public_id': '',\n",
" 'tool_name': 'Amazon Connect TEI (local seed)',\n",
" 'report_name': 'Total Economic Impact™ Of Amazon Connect',\n",
" 'report_vendor': 'AWS',\n",
" 'report_version': '1.0',\n",
" 'generated_at': datetime.now(timezone.utc).isoformat(),\n",
" 'generator': f'palladium core {__version__} (offline)',\n",
" },\n",
" 'report': {\n",
" 'name': 'Total Economic Impact™ Of Amazon Connect',\n",
" 'vendor': 'AWS',\n",
" 'version': '1.0',\n",
" 'discount_rate': config.DISCOUNT_RATE,\n",
" 'analysis_period_years': config.ANALYSIS_YEARS,\n",
" },\n",
" 'values': {'benefits': seed_data.BENEFITS, 'costs': seed_data.COSTS},\n",
" 'summary': summary,\n",
" 'scenarios': scenarios,\n",
" 'assumptions': seed_data.ASSUMPTIONS,\n",
" }\n",
" source = 'offline seed data'\n",
"\n",
"display.alert(f'Envelope built from <b>{source}</b>.', 'info')"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "98e94d07",
"metadata": {},
"outputs": [],
"source": [
"out_path = STUDY / 'exports' / 'export.json'\n",
"out_path.parent.mkdir(parents=True, exist_ok=True)\n",
"out_path.write_text(json.dumps(envelope, indent=2, default=str))\n",
"size_kb = out_path.stat().st_size / 1024\n",
"display.alert(f'Wrote <code>{out_path.relative_to(ROOT)}</code> ({size_kb:.1f} KB).', 'success')"
]
},
{
"cell_type": "markdown",
"id": "d09cad64",
"metadata": {},
"source": [
"## Envelope shape\n",
"\n",
"Top-level keys consumed by the report pipeline:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "841f12a1",
"metadata": {},
"outputs": [],
"source": [
"for key in envelope:\n",
" sub = envelope[key]\n",
" if isinstance(sub, dict):\n",
" print(f' {key}: dict with keys {list(sub.keys())}')\n",
" elif isinstance(sub, list):\n",
" print(f' {key}: list[{len(sub)}]')\n",
" else:\n",
" print(f' {key}: {type(sub).__name__}')"
]
},
{
"cell_type": "markdown",
"id": "17d6d0ce",
"metadata": {},
"source": [
"Done. Hand off `exports/export.json` to **Peitho** / **html2docx** to produce the final Word report."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.7"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -0,0 +1,162 @@
"""
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)``.
Numbers are the *nominal* (pre-risk-adjustment) values from the PDF —
risk adjustment is stored as a factor and applied by Athena's
calculator (or, locally, by ``core.calculations.risk_adjust_*``).
References for the totals (from the PDF):
Benefits (3-yr risk-adjusted PV @ 10%): $101,696,791
Costs (3-yr risk-adjusted PV @ 10%): $ 22,983,076
NPV $ 78,713,715
ROI 342%
Payback <6 months
"""
from __future__ import annotations
#: 3-year nominal benefit cashflows. Risk adjustment factor is stored
#: separately; calculator applies it.
BENEFITS: list[dict] = [
{
"field_key": "ai_contact_resolution",
"table": "benefits",
"label": "AI-driven contact resolution efficiency",
"category": "Productivity",
"year_values": {"1": 13_911_040, "2": 23_932_480, "3": 37_797_760},
"risk_adjustment": 0.15,
"notes": (
"PDF Section At/Atr. Composite: 20M annual contacts, 30% YoY "
"growth, 75% calls, 10-min AHT with legacy. Connect drops AHT "
"12% Y1 and shifts traffic to chat/self-service. 80% "
"productivity recapture. Risk adj 15% (legacy performance, "
"implementation depth, integration scope, growth)."
),
},
{
"field_key": "ai_content_sentiment",
"table": "benefits",
"label": "AI-powered content and sentiment analysis savings",
"category": "Productivity",
"year_values": {"1": 4_586_620, "2": 5_358_412, "3": 6_291_680},
"risk_adjustment": 0.15,
"notes": (
"PDF Section Bt/Btr. Auto post-contact summaries reclaim ~60s "
"per call; QA scaled from 13% to 100%; supervisors freed from "
"manual review. Risk adj 15%."
),
},
{
"field_key": "ai_forecasting_supervision",
"table": "benefits",
"label": "AI-enabled forecasting, agent scheduling, and supervision",
"category": "Productivity",
"year_values": {"1": 6_651_680, "2": 9_133_760, "3": 12_391_712},
"risk_adjustment": 0.15,
"notes": (
"PDF Section Ct/Ctr. ML-WFM yields 5% agent FTE optimization "
"and supervisors managing 20% more agents (10→12). 80% "
"productivity recapture. Risk adj 15%."
),
},
{
"field_key": "data_driven_profit_lift",
"table": "benefits",
"label": "Data-driven profit lift with increased conversion",
"category": "Revenue",
"year_values": {"1": 1_200_000, "2": 1_560_000, "3": 2_028_000},
"risk_adjustment": 0.20,
"notes": (
"PDF Section Dt/Dtr. Composite revenue $10B Y1 (+30% YoY); "
"5% from outbound contact-center marketing; conversion lifts "
"from 10% to 12% (+20% relative); 12% operating margin. "
"Risk adj 20%."
),
},
{
"field_key": "legacy_solution_savings",
"table": "benefits",
"label": "Legacy solution cost savings",
"category": "Cost Savings",
"year_values": {"1": 6_177_600, "2": 8_030_880, "3": 10_440_144},
"risk_adjustment": 0.20,
"notes": (
"PDF Section Et/Etr. Avg legacy license $180/agent-month × "
"(agents+supervisors) × 12, plus 30% overhead for infra & "
"third-party tools. Risk adj 20%."
),
},
]
#: Costs include an "initial" (year-0, undiscounted) component for
#: implementation. Cost risk adjustments are applied *upward*.
COSTS: list[dict] = [
{
"field_key": "amazon_connect_usage",
"table": "costs",
"label": "Amazon Connect usage cost",
"category": "Subscription",
"initial": 0,
"year_values": {"1": 6_456_448, "2": 7_951_164, "3": 9_832_961},
"risk_adjustment": 0.05,
"notes": (
"PDF Section Ft/Ftr. Telephony $0.0106/min + Unlimited AI "
"$0.0380/min on minutes that reach an agent, plus chat at "
"$0.0100/message (10 messages/chat). Risk adj 5%."
),
},
{
"field_key": "implementation_migration",
"table": "costs",
"label": "Implementation and migration cost",
"category": "Implementation",
"initial": 1_087_500,
"year_values": {"1": 188_333, "2": 188_333, "3": 0},
"risk_adjustment": 0.10,
"notes": (
"PDF Section Gt/Gtr. 6-month initial migration: 5 internal "
"FTE @ $115k + $800k pro-services. Y1/Y2 M&A integrations: 2 "
"months × 2 FTE + $150k pro-services. Risk adj 10%."
),
},
{
"field_key": "ongoing_management",
"table": "costs",
"label": "Ongoing management",
"category": "Operations",
"initial": 0,
"year_values": {"1": 256_200, "2": 187_200, "3": 187_200},
"risk_adjustment": 0.15,
"notes": (
"PDF Section Ht/Htr. Y1: 5 IT/PM @ 30% × $115k + 5 business "
"users @ 30% × $55,800. Y2/Y3: 3 IT/PM @ 30% + 5 business "
"users @ 30%. Risk adj 15%."
),
},
]
#: Top-line composite assumptions — for the 03_business_case narrative.
ASSUMPTIONS: dict = {
"agents_fte": 2_000,
"supervisors_fte": 200,
"annual_contacts_y1": 20_000_000,
"growth_rate": 0.30,
"call_share": 0.75,
"aht_legacy_minutes": 10,
"agent_salary": 45_760,
"supervisor_salary": 55_800,
"discount_rate": 0.10,
"analysis_years": 3,
}
def all_values() -> list[dict]:
"""Return BENEFITS + COSTS — handy single-call payload for update_values."""
return BENEFITS + COSTS

0
studies/__init__.py Normal file
View File

0
tests/__init__.py Normal file
View File

32
tests/conftest.py Normal file
View File

@@ -0,0 +1,32 @@
"""Pytest fixtures for Palladium tests."""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
@pytest.fixture(autouse=True)
def _env(monkeypatch):
"""Default test env vars so TEIClient() doesn't need a real .env."""
monkeypatch.setenv("ATHENA_BASE_URL", "https://athena.test")
monkeypatch.setenv("ATHENA_API_KEY", "test-key")
@pytest.fixture
def amazon_connect_seed():
"""Load the Amazon Connect study's seed data."""
sys.path.insert(0, str(ROOT / "studies" / "202602_AmazonConnect"))
try:
import seed_data # type: ignore[import-not-found]
return seed_data
finally:
# Leave the path alone — many tests will use the seed
pass

206
tests/test_calculations.py Normal file
View File

@@ -0,0 +1,206 @@
"""
Tests for core.calculations — reproduces the Forrester Amazon Connect TEI
totals from the published study within rounding.
The PDF reports (Cash Flow Analysis, p.25):
Benefits PV (RA) = $101,696,791
Costs PV (RA) = $ 22,983,076
NPV = $ 78,713,715
ROI = 342%
Payback = <6 months
"""
from __future__ import annotations
import pytest
from core.calculations import (
SCENARIOS,
apply_scenario,
discount_factor,
npv,
payback_months,
payback_years,
present_value,
present_value_series,
risk_adjust_benefit,
risk_adjust_cost,
roi,
roi_percentage,
)
# ─────────────────────────────────────────────
# Building blocks
# ─────────────────────────────────────────────
class TestDiscounting:
def test_discount_factor_year_zero(self):
assert discount_factor(0, 0.10) == pytest.approx(1.0)
def test_discount_factor_known_value(self):
# 1/(1.10)^3
assert discount_factor(3, 0.10) == pytest.approx(0.7513148, rel=1e-5)
def test_negative_year_raises(self):
with pytest.raises(ValueError):
discount_factor(-1, 0.10)
def test_present_value_year_one(self):
assert present_value(110, 1, 0.10) == pytest.approx(100.0)
def test_present_value_series_three_years(self):
# 100 each year for 3 years at 10% → ≈ 248.685
assert present_value_series([100, 100, 100], 0.10) == pytest.approx(
248.685, rel=1e-3
)
class TestNPV:
def test_zero_initial(self):
assert npv([100, 100], 0.0) == pytest.approx(200.0)
def test_with_initial(self):
# 1000 invested up-front, 600 returned each of 2 years at 10%
result = npv([600, 600], 0.10, initial=-1000)
# PV of returns ≈ 545.45 + 495.87 = 1041.32, NPV ≈ 41.32
assert result == pytest.approx(41.32, abs=0.5)
class TestRiskAdjustment:
def test_benefit_zero_risk(self):
assert risk_adjust_benefit(100, 0.0) == 100
def test_benefit_15pct(self):
assert risk_adjust_benefit(100, 0.15) == pytest.approx(85.0)
def test_cost_5pct_upward(self):
assert risk_adjust_cost(100, 0.05) == pytest.approx(105.0)
def test_clamping(self):
assert risk_adjust_benefit(100, 1.5) == 0 # clamped to 1.0
assert risk_adjust_benefit(100, -0.5) == 100 # clamped to 0
class TestROI:
def test_zero_costs_returns_zero(self):
assert roi(100, 0) == 0.0
def test_known(self):
assert roi_percentage(101_696_791, 22_983_076) == pytest.approx(342, abs=1)
class TestPayback:
def test_immediate(self):
assert payback_years(0, [100]) == 0.0
def test_amazon_connect_under_six_months(self):
# Initial $1.196M, Y1 net ~$20M → quick crossing
years = payback_years(1_196_250, [19_997_953, 31_562_489, 47_443_905])
assert years is not None
assert payback_months(1_196_250, [19_997_953, 31_562_489, 47_443_905]) < 6
def test_never_recovered(self):
assert payback_years(1000, [100, 100, 100]) is None
class TestScenarios:
def test_default_scenarios_present(self):
assert set(SCENARIOS) == {"conservative", "moderate", "aggressive"}
def test_moderate_is_passthrough(self):
items = [
{
"table": "benefits",
"field_key": "x",
"year_values": {"1": 1000, "2": 2000},
"risk_adjustment": 0.15,
}
]
out = apply_scenario(items, "moderate")
assert out[0]["year_values"] == {"1": 1000.0, "2": 2000.0}
assert out[0]["risk_adjustment"] == pytest.approx(0.15)
def test_conservative_lowers_benefits(self):
items = [
{
"table": "benefits",
"field_key": "x",
"year_values": {"1": 1000},
"risk_adjustment": 0.15,
}
]
out = apply_scenario(items, "conservative")
assert out[0]["year_values"]["1"] == pytest.approx(800.0)
# 0.15 + 0.10 = 0.25
assert out[0]["risk_adjustment"] == pytest.approx(0.25)
def test_aggressive_increases_benefits(self):
items = [
{
"table": "benefits",
"field_key": "x",
"year_values": {"1": 1000},
"risk_adjustment": 0.15,
}
]
out = apply_scenario(items, "aggressive")
assert out[0]["year_values"]["1"] == pytest.approx(1150.0)
assert out[0]["risk_adjustment"] == pytest.approx(0.10)
def test_unknown_scenario_raises(self):
with pytest.raises(KeyError):
apply_scenario([], "purple")
# ─────────────────────────────────────────────
# End-to-end: reproduce the PDF totals
# ─────────────────────────────────────────────
class TestAmazonConnectComposite:
"""Reproduce the Forrester Amazon Connect TEI numbers within rounding."""
DISCOUNT_RATE = 0.10
EXPECTED_BENEFITS_PV = 101_696_791
EXPECTED_COSTS_PV = 22_983_076
EXPECTED_NPV = 78_713_715
EXPECTED_ROI = 342 # percent
TOLERANCE = 1_500 # dollars; PDF rounding at thousands
def _benefits_pv(self, seed) -> float:
total = 0.0
for b in seed.BENEFITS:
rf = b["risk_adjustment"]
yr = [b["year_values"][str(y)] for y in (1, 2, 3)]
yr_ra = [risk_adjust_benefit(v, rf) for v in yr]
total += npv(yr_ra, self.DISCOUNT_RATE)
return total
def _costs_pv(self, seed) -> float:
total = 0.0
for c in seed.COSTS:
rf = c["risk_adjustment"]
init = risk_adjust_cost(c.get("initial") or 0, rf)
yr = [c["year_values"][str(y)] for y in (1, 2, 3)]
yr_ra = [risk_adjust_cost(v, rf) for v in yr]
total += npv(yr_ra, self.DISCOUNT_RATE, initial=init)
return total
def test_benefits_pv(self, amazon_connect_seed):
result = self._benefits_pv(amazon_connect_seed)
assert result == pytest.approx(self.EXPECTED_BENEFITS_PV, abs=self.TOLERANCE)
def test_costs_pv(self, amazon_connect_seed):
result = self._costs_pv(amazon_connect_seed)
assert result == pytest.approx(self.EXPECTED_COSTS_PV, abs=self.TOLERANCE)
def test_npv(self, amazon_connect_seed):
b = self._benefits_pv(amazon_connect_seed)
c = self._costs_pv(amazon_connect_seed)
assert (b - c) == pytest.approx(self.EXPECTED_NPV, abs=self.TOLERANCE)
def test_roi(self, amazon_connect_seed):
b = self._benefits_pv(amazon_connect_seed)
c = self._costs_pv(amazon_connect_seed)
assert roi_percentage(b, c) == pytest.approx(self.EXPECTED_ROI, abs=1)

174
tests/test_client.py Normal file
View File

@@ -0,0 +1,174 @@
"""
TEI client tests with mocked HTTP.
We mock ``requests.Session.request`` so tests do not require network access
or a live Athena instance.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import pytest
from core.tei_client import AthenaAPIError, TEIClient
def _mock_response(status: int, body=None) -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.content = b"{}" if body is None else json.dumps(body).encode()
resp.json.return_value = body if body is not None else {}
resp.text = json.dumps(body or {})
return resp
@pytest.fixture
def client(monkeypatch) -> TEIClient:
c = TEIClient()
c.session = MagicMock()
return c
class TestConfig:
def test_requires_base_url(self, monkeypatch):
monkeypatch.delenv("ATHENA_BASE_URL", raising=False)
with pytest.raises(ValueError, match="ATHENA_BASE_URL"):
TEIClient(api_key="x")
def test_requires_api_key(self, monkeypatch):
monkeypatch.delenv("ATHENA_API_KEY", raising=False)
with pytest.raises(ValueError, match="ATHENA_API_KEY"):
TEIClient(base_url="https://example.com")
def test_authorization_header(self):
c = TEIClient(base_url="https://example.com", api_key="abc123")
assert c.session.headers["Authorization"] == "Api-Key abc123"
class TestPaths:
"""Verify each endpoint targets the documented URL."""
def _last_call_url(self, client: TEIClient) -> str:
return client.session.request.call_args.kwargs["url"]
def test_list_reports_path(self, client):
client.session.request.return_value = _mock_response(
200, {"results": [], "next": None}
)
client.list_reports()
assert self._last_call_url(client) == "https://athena.test/api/v1/tei/reports/"
def test_get_tool_path(self, client):
client.session.request.return_value = _mock_response(200, {"id": "abc"})
client.get_tool("abc123")
assert self._last_call_url(client).endswith("/api/v1/tei/tools/abc123/")
def test_calculate_path(self, client):
client.session.request.return_value = _mock_response(200, {})
client.calculate("abc")
assert self._last_call_url(client).endswith("/api/v1/tei/tools/abc/calculate/")
assert client.session.request.call_args.kwargs["method"] == "POST"
def test_export_path(self, client):
client.session.request.return_value = _mock_response(200, {})
client.export("abc")
assert self._last_call_url(client).endswith("/api/v1/tei/tools/abc/export/")
def test_aggregate_summary_path(self, client):
client.session.request.return_value = _mock_response(200, {})
client.aggregate_summary()
assert self._last_call_url(client).endswith("/api/v1/tei/summary/")
def test_save_version_path(self, client):
client.session.request.return_value = _mock_response(201, {"version_number": 1})
client.save_version("abc", note="initial")
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"}
class TestErrorHandling:
def test_404_raises_athena_error(self, client):
client.session.request.return_value = _mock_response(
404, {"detail": "Not found"}
)
with pytest.raises(AthenaAPIError) as ei:
client.get_tool("missing")
assert ei.value.status_code == 404
assert "Not found" in ei.value.detail
def test_test_connection_returns_error_dict(self, client):
client.session.request.return_value = _mock_response(
401, {"detail": "Invalid token"}
)
result = client.test_connection()
assert result["status"] == "error"
assert result["authenticated"] is False
assert result["error_code"] == 401
class TestPagination:
def test_walks_next_links(self, client):
# First page returns one item with a `next` URL; second page returns
# one more item and no next.
page1 = _mock_response(
200,
{
"results": [{"id": 1}],
"next": "https://athena.test/api/v1/tei/reports/?page=2",
},
)
page2 = _mock_response(200, {"results": [{"id": 2}], "next": None})
client.session.request.return_value = page1
client.session.get.return_value = page2 # follow next via session.get
out = client.list_reports()
assert [r["id"] for r in out] == [1, 2]
class TestNormalizeValue:
def test_year_underscore_keys(self):
out = TEIClient._normalize_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
def test_year_values_dict_passthrough(self):
out = TEIClient._normalize_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 "
def test_initial_carried(self):
out = TEIClient._normalize_value(
{"field_key": "x", "initial": 1000, "year_1": 5}
)
assert out["initial"] == 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
class TestUpdateValuesPayload:
def test_wraps_in_envelope(self, client):
client.session.request.return_value = _mock_response(200, {})
client.update_values(
"abc",
[{"field_key": "x", "year_1": 100}, {"field_key": "y", "year_1": 200}],
)
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}

98
tests/test_export.py Normal file
View File

@@ -0,0 +1,98 @@
"""Tests for core.export.report_data — envelope shape and computed totals."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from core.export import build_report_data
from core.export.report_data import _compute_summary, _yearly_totals
class TestComputeSummary:
def test_amazon_connect_totals(self, amazon_connect_seed):
s = _compute_summary(
amazon_connect_seed.BENEFITS,
amazon_connect_seed.COSTS,
0.10,
3,
)
assert s["total_benefits_pv"] == pytest.approx(101_696_791, abs=1500)
assert s["total_costs_pv"] == pytest.approx(22_983_076, abs=1500)
assert s["npv"] == pytest.approx(78_713_715, abs=2000)
assert s["roi_pct"] == pytest.approx(342, abs=1)
assert s["payback_months"] is not None and s["payback_months"] < 6
def test_yearly_breakdown_three_rows(self, amazon_connect_seed):
s = _compute_summary(
amazon_connect_seed.BENEFITS, amazon_connect_seed.COSTS, 0.10, 3
)
assert len(s["yearly_breakdown"]) == 3
assert [r["year"] for r in s["yearly_breakdown"]] == [1, 2, 3]
class TestYearlyTotals:
def test_only_within_horizon(self):
items = [
{"year_values": {"1": 100, "2": 200, "3": 300, "4": 999}},
]
assert _yearly_totals(items, 3) == [100.0, 200.0, 300.0]
def test_skips_invalid_keys(self):
items = [{"year_values": {"1": 50, "abc": 999}}]
assert _yearly_totals(items, 2) == [50.0, 0.0]
class TestBuildReportData:
def _stub_client(self, seed):
c = MagicMock()
c.get_tool_with_data.return_value = {
"tool": {"id": "pid", "name": "T", "report": "rid", "proposal": 7},
"fields": [],
"values": seed.BENEFITS + seed.COSTS,
}
c.get_report.return_value = {
"id": "rid",
"name": "Amazon Connect",
"vendor": "AWS",
"version": "1.0",
"discount_rate": "0.10",
"analysis_period_years": 3,
}
c.export.return_value = {"echoed": True}
return c
def test_envelope_shape(self, amazon_connect_seed):
client = self._stub_client(amazon_connect_seed)
env = build_report_data(client, "pid", study_slug="202602_AmazonConnect")
assert set(env) >= {
"metadata",
"report",
"fields",
"values",
"summary",
"athena_export",
"scenarios",
}
assert env["metadata"]["study_slug"] == "202602_AmazonConnect"
assert env["metadata"]["proposal"] == 7
assert env["values"]["benefits"]
assert env["values"]["costs"]
def test_scenarios_have_three_keys(self, amazon_connect_seed):
client = self._stub_client(amazon_connect_seed)
env = build_report_data(client, "pid")
assert set(env["scenarios"]) == {"conservative", "moderate", "aggressive"}
def test_no_scenarios_flag(self, amazon_connect_seed):
client = self._stub_client(amazon_connect_seed)
env = build_report_data(client, "pid", include_scenarios=False)
assert "scenarios" not in env
def test_local_summary_matches_seed(self, amazon_connect_seed):
client = self._stub_client(amazon_connect_seed)
env = build_report_data(client, "pid", include_scenarios=False)
assert env["summary"]["total_benefits_pv"] == pytest.approx(
101_696_791, abs=1500
)