feat: add locale formatting config and update notebook outputs

Add configurable locale/display formatting environment variables
(`PALLADIUM_CURRENCY_SYMBOL`, `PALLADIUM_THOUSANDS_SEP`,
`PALLADIUM_DECIMAL_SEP`) to support regional number formatting in the
Streamlit app. Update `.env.example` with documentation for these new
variables.

Also refresh `00_setup.ipynb` with current execution outputs reflecting
a live Athena connection with report templates, a selected client
(Global Guardian Insurance, ID=2), and resolved NameError in assumption
override cells.
This commit is contained in:
2026-06-10 11:54:28 -04:00
parent 253ff38118
commit ecd164ee6d
13 changed files with 839 additions and 111 deletions

View File

@@ -10,3 +10,16 @@ ATHENA_API_KEY=your-api-key-here
# PALLADIUM_TOOL_PUBLIC_ID=
# PALLADIUM_PROPOSAL_ID=
# PALLADIUM_ENGAGEMENT_ID=
# ---------------------------------------------------------------------------
# Locale / display formatting (Streamlit app)
# ---------------------------------------------------------------------------
# Currency symbol prefix (default: $)
# PALLADIUM_CURRENCY_SYMBOL=$
#
# Thousands separator (default: , for Americas/UK; use . for continental Europe)
# PALLADIUM_THOUSANDS_SEP=,
#
# Decimal separator (default: . for Americas/UK; use , for continental Europe)
# PALLADIUM_DECIMAL_SEP=.

View File

@@ -22,7 +22,7 @@
},
{
"cell_type": "code",
"execution_count": 1,
"execution_count": 6,
"id": "53fcc345",
"metadata": {},
"outputs": [
@@ -32,7 +32,7 @@
"Palladium(root='palladium', athena='not tested')"
]
},
"execution_count": 1,
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
@@ -66,7 +66,7 @@
},
{
"cell_type": "code",
"execution_count": 2,
"execution_count": 7,
"id": "853aaab8",
"metadata": {},
"outputs": [
@@ -102,7 +102,7 @@
},
{
"cell_type": "code",
"execution_count": 3,
"execution_count": 8,
"id": "9b7fcc97",
"metadata": {},
"outputs": [
@@ -110,7 +110,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
"✅ Athena connected — https://athena.ouranos.helu.ca (0 report templates visible)\n"
"✅ Athena connected — https://athena.ouranos.helu.ca (1 report templates visible)\n"
]
},
{
@@ -119,11 +119,11 @@
"{'status': 'ok',\n",
" 'base_url': 'https://athena.ouranos.helu.ca',\n",
" 'authenticated': True,\n",
" 'reports_found': 0,\n",
" 'timestamp': '2026-06-10T06:45:10.418874'}"
" 'reports_found': 1,\n",
" 'timestamp': '2026-06-10T07:08:06.947037'}"
]
},
"execution_count": 3,
"execution_count": 8,
"metadata": {},
"output_type": "execute_result"
}
@@ -144,16 +144,69 @@
},
{
"cell_type": "code",
"execution_count": 4,
"execution_count": 9,
"id": "83edbe4d",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"No TEI report templates yet — studies/202602_AmazonConnect/notebooks/00_provision.ipynb creates one.\n"
]
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>id</th>\n",
" <th>name</th>\n",
" <th>vendor</th>\n",
" <th>version</th>\n",
" <th>status</th>\n",
" <th>analysis_period_years</th>\n",
" <th>discount_rate</th>\n",
" <th>field_count</th>\n",
" <th>instance_count</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>xsUTbjh4iDnJ</td>\n",
" <td>Amazon Connect 2026</td>\n",
" <td>AWS</td>\n",
" <td>1.0</td>\n",
" <td>active</td>\n",
" <td>3</td>\n",
" <td>0.1000</td>\n",
" <td>11</td>\n",
" <td>0</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" id name vendor version status \\\n",
"0 xsUTbjh4iDnJ Amazon Connect 2026 AWS 1.0 active \n",
"\n",
" analysis_period_years discount_rate field_count instance_count \n",
"0 3 0.1000 11 0 "
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
@@ -172,7 +225,7 @@
},
{
"cell_type": "code",
"execution_count": 5,
"execution_count": 10,
"id": "a247bedd",
"metadata": {},
"outputs": [
@@ -209,6 +262,30 @@
"2. **Work the study** → notebooks `01_benefits` → `04_export` in the same folder.\n",
"3. **Interactive data entry** → `make app` (or `streamlit run app/main.py`)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d20d824f-e464-4ff7-8191-10c2495842a0",
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "630ee935-7c7b-47e5-9c13-6285316823e2",
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "7eba3877-8e51-443f-9953-9d0a48425f9f",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {

View File

@@ -261,7 +261,7 @@ palladium/
│ └── __main__.py
├── app/ # Streamlit UI — works with any TEI study
│ ├── main.py # entry point
│ ├── pages/ # benefits, costs, summary, versions
│ ├── views/ # benefits, costs, summary, versions (NOT `pages/` — avoids Streamlit auto-multipage)
│ └── components/ # tables, charts
├── studies/ # One folder per TEI engagement
│ └── 202602_AmazonConnect/

View File

@@ -9,24 +9,24 @@ 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)
st.plotly_chart(fig, width="stretch")
def benefits_bar(items) -> None:
fig = core_charts.benefits_bar(items)
st.plotly_chart(fig, use_container_width=True)
st.plotly_chart(fig, width="stretch")
def cost_pie(items) -> None:
fig = core_charts.cost_breakdown_pie(items)
st.plotly_chart(fig, use_container_width=True)
st.plotly_chart(fig, width="stretch")
def scenario_bars(scenarios) -> None:
fig = core_charts.scenario_comparison(scenarios)
st.plotly_chart(fig, use_container_width=True)
st.plotly_chart(fig, width="stretch")
def waterfall(values) -> None:
fig = core_charts.waterfall(values)
st.plotly_chart(fig, use_container_width=True)
st.plotly_chart(fig, width="stretch")

View File

@@ -5,9 +5,11 @@ from __future__ import annotations
import pandas as pd
import streamlit as st
from app.locale import currency_fmt, fmt_currency, fmt_pct, pct_fmt, _STANDARD_LOCALE
def _years_for_table(fields: list[dict], analysis_years: int) -> list[int]:
"""Years 1..N taken from analysis_period_years on the report."""
"""Years 1..N -- taken from analysis_period_years on the report."""
return list(range(1, max(int(analysis_years or 3), 1) + 1))
@@ -26,6 +28,11 @@ def value_editor(
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.
Currency columns use the locale configured via PALLADIUM_CURRENCY_SYMBOL /
PALLADIUM_THOUSANDS_SEP / PALLADIUM_DECIMAL_SEP in .env.
The risk_adj column is stored as a 0-1 fraction and displayed as a
percentage (e.g. 0.20 -> "20.00%").
"""
fields = [
f
@@ -44,43 +51,76 @@ def value_editor(
for f in fields:
v = by_key.get(f["field_key"], {}) or {}
yv = v.get("year_values") or {}
risk_raw = float(v.get("risk_adjustment") or 0.0)
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)
if _STANDARD_LOCALE:
row["Initial"] = float(v.get("initial") or 0.0)
else:
row["Initial"] = fmt_currency(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)
raw = float(yv.get(str(y)) or 0.0)
if _STANDARD_LOCALE:
row[f"Year {y}"] = raw
else:
row[f"Year {y}"] = fmt_currency(raw)
# Risk adj: store as fraction for standard locales (NumberColumn handles
# display), or pre-format as "20.00%" string for non-standard locales.
if _STANDARD_LOCALE:
row["risk_adj"] = risk_raw
else:
row["risk_adj"] = fmt_pct(risk_raw)
row["notes"] = v.get("notes", "") or ""
rows.append(row)
df = pd.DataFrame(rows)
_cur_fmt = currency_fmt()
_pct_fmt_str = pct_fmt()
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"
if _STANDARD_LOCALE:
column_config["risk_adj"] = st.column_config.NumberColumn(
"Risk Adj.",
min_value=0.0,
max_value=1.0,
step=0.05,
format=_pct_fmt_str,
help="Enter as a decimal fraction (e.g. 0.20 = 20%)",
)
for y in years:
column_config[f"Year {y}"] = st.column_config.NumberColumn(
f"Year {y}", format="$%.0f"
if table == "costs":
column_config["Initial"] = st.column_config.NumberColumn(
"Initial", format=_cur_fmt
)
for y in years:
column_config[f"Year {y}"] = st.column_config.NumberColumn(
f"Year {y}", format=_cur_fmt
)
else:
# Non-standard locale: display as pre-formatted strings (read-only display;
# user edits the raw number and we re-format on save).
column_config["risk_adj"] = st.column_config.TextColumn(
"Risk Adj.", help="Displayed as percentage; stored as 0-1 fraction"
)
if table == "costs":
column_config["Initial"] = st.column_config.TextColumn("Initial")
for y in years:
column_config[f"Year {y}"] = st.column_config.TextColumn(f"Year {y}")
edited = st.data_editor(
df,
column_config=column_config,
use_container_width=True,
width="stretch",
num_rows="fixed",
hide_index=True,
key=key,

87
app/locale.py Normal file
View File

@@ -0,0 +1,87 @@
"""
Locale / formatting settings for the Palladium Streamlit app.
All settings are read from environment variables (via .env) so the same
codebase can be deployed for different regions without code changes.
Environment variables
---------------------
PALLADIUM_CURRENCY_SYMBOL Default: "$"
Prefix shown before monetary values (e.g. "$", "", "£", "CAD ").
PALLADIUM_THOUSANDS_SEP Default: ","
Thousands separator used in number display (e.g. "," for Americas,
"." for continental Europe, " " for some locales).
PALLADIUM_DECIMAL_SEP Default: "."
Decimal separator (e.g. "." for Americas/UK, "," for continental Europe).
Note: Streamlit's NumberColumn ``format`` uses printf-style strings.
The ``%,`` flag (thousands separator) is supported in Streamlit ≥ 1.31.
For non-standard separators (e.g. European "." thousands / "," decimal)
the values are pre-formatted as strings and displayed in TextColumns.
"""
from __future__ import annotations
import os
def _env(key: str, default: str) -> str:
return os.environ.get(key, default).strip()
# ---------------------------------------------------------------------------
# Resolved settings (read once at import time; restart app to pick up changes)
# ---------------------------------------------------------------------------
CURRENCY_SYMBOL: str = _env("PALLADIUM_CURRENCY_SYMBOL", "$")
THOUSANDS_SEP: str = _env("PALLADIUM_THOUSANDS_SEP", ",")
DECIMAL_SEP: str = _env("PALLADIUM_DECIMAL_SEP", ".")
# True when the locale uses standard printf-compatible separators
# (i.e. "," thousands + "." decimal — the C/POSIX default).
# When False, we pre-format values as strings instead of relying on printf.
_STANDARD_LOCALE: bool = THOUSANDS_SEP == "," and DECIMAL_SEP == "."
def currency_fmt() -> str:
"""Return a Streamlit NumberColumn ``format`` string for currency.
For standard locales returns e.g. ``"$%,.0f"`` (thousands-separated,
no decimal places). For non-standard locales returns ``"%s"`` and
callers should use :func:`fmt_currency` to pre-format the value.
"""
if _STANDARD_LOCALE:
return f"{CURRENCY_SYMBOL}%,.0f"
return "%s"
def pct_fmt() -> str:
"""Return a Streamlit NumberColumn ``format`` string for percentages.
Stores the value as a fraction (01) and displays as e.g. ``"20.00%"``.
Streamlit's ``%%`` in format strings renders a literal ``%``.
"""
if _STANDARD_LOCALE:
return "%.2f%%"
return "%s"
def fmt_currency(value: float) -> str:
"""Format *value* as a currency string using the configured locale."""
if _STANDARD_LOCALE:
return f"{CURRENCY_SYMBOL}{value:,.0f}"
# Non-standard: build manually
integer_part = f"{int(abs(value)):,}".replace(",", THOUSANDS_SEP)
sign = "-" if value < 0 else ""
return f"{sign}{CURRENCY_SYMBOL}{integer_part}"
def fmt_pct(value: float) -> str:
"""Format *value* (01 fraction) as a percentage string."""
pct = value * 100
if _STANDARD_LOCALE:
return f"{pct:.2f}%"
integer_part = f"{int(pct)}"
decimal_part = f"{abs(pct) % 1:.2f}"[1:] # ".xx"
return f"{integer_part}{DECIMAL_SEP}{decimal_part[1:]}%"

View File

@@ -24,6 +24,7 @@ if str(_ROOT) not in sys.path:
import streamlit as st
from core.tei_client import AthenaAPIError, TEIClient
from app.utils import icon, inject_icons
st.set_page_config(
page_title="Palladium — TEI Calculator",
@@ -32,6 +33,7 @@ st.set_page_config(
)
@st.cache_resource(show_spinner=False)
def get_client() -> TEIClient:
return TEIClient()
@@ -75,7 +77,9 @@ def _crm_engagements(_client: TEIClient, client_name: str) -> list[dict]:
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.markdown(
f"{icon('shield-fill')} **Palladium**", unsafe_allow_html=True
)
st.sidebar.caption("TEI Calculator")
tools = _safe_call(client.list_tools) or []
@@ -163,15 +167,20 @@ def sidebar_tool_picker(client: TEIClient) -> dict | None:
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"):
if st.sidebar.button("Recalculate"):
_safe_call(client.calculate, tool["id"])
st.toast("Recalculated.", icon="")
st.toast("Recalculated.", icon=None)
st.cache_data.clear()
return tool
def main() -> None:
st.title("Palladium — TEI Calculator")
inject_icons()
st.markdown(
f"<h1 style='margin-bottom:0'>{icon('shield-fill')} Palladium — TEI Calculator</h1>",
unsafe_allow_html=True,
)
try:
client = get_client()
except ValueError as e:
@@ -186,14 +195,19 @@ def main() -> 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
# Tab navigation — matches `app/views/*` modules but kept as tabs so all
# views share the chosen tool/state without re-querying.
tabs = st.tabs(["📊 Summary", "💰 Benefits", "💸 Costs", "🕒 Versions"])
#
# NOTE: the directory is `app/views/`, NOT `app/pages/`. Streamlit treats a
# `pages/` directory next to the entrypoint as auto-discovered multipage
# scripts, which would render blank since these modules only define
# `render()` and have no top-level output.
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
from app.views import benefits as benefits_page
from app.views import costs as costs_page
from app.views import summary as summary_page
from app.views import versions as versions_page
with tabs[0]:
summary_page.render(client, tool)

45
app/utils.py Normal file
View File

@@ -0,0 +1,45 @@
"""
Shared UI utilities for the Palladium Streamlit app.
Kept in a separate module so that ``app.main`` and ``app.views.*`` can both
import from here without creating a circular dependency.
"""
from __future__ import annotations
import streamlit as st
# ---------------------------------------------------------------------------
# Bootstrap Icons — injected once at the top of every page render.
# Using the CDN stylesheet so no npm/build step is needed.
# ---------------------------------------------------------------------------
_BI_CSS = """
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/>
<style>
/* Tighten up the default Streamlit header spacing */
.block-container { padding-top: 1.5rem; }
/* Make BI icons align nicely with surrounding text */
.bi { vertical-align: -0.125em; }
</style>
"""
def inject_icons() -> None:
"""Inject Bootstrap Icons CSS (idempotent — Streamlit deduplicates identical HTML)."""
st.markdown(_BI_CSS, unsafe_allow_html=True)
def icon(name: str, *, cls: str = "") -> str:
"""Return an inline Bootstrap Icon ``<i>`` tag.
Usage::
st.markdown(icon("bar-chart") + " Financial Summary", unsafe_allow_html=True)
See the full icon catalogue at https://icons.getbootstrap.com/
"""
extra = f" {cls}" if cls else ""
return f'<i class="bi bi-{name}{extra}"></i>'

View File

@@ -5,12 +5,17 @@ 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 app.utils import icon
from app.views._helpers import report_meta, safe
from core.tei_client import TEIClient
def render(client: TEIClient, tool: dict) -> None:
st.header("💰 Benefits")
st.markdown(
f"<h2>{icon('graph-up-arrow')} Benefits</h2>",
unsafe_allow_html=True,
)
public_id = tool["id"]
report = report_meta(client, tool)
analysis_years = int(report.get("analysis_period_years") or 3)
@@ -32,7 +37,8 @@ def render(client: TEIClient, tool: dict) -> None:
col1, col2 = st.columns([1, 4])
with col1:
if st.button("💾 Save benefits", use_container_width=True):
if st.button("Save benefits", width="stretch"):
payload = df_to_values(edited, "benefits", analysis_years)
result = safe(client.update_values, public_id, payload)
if result is not None:

View File

@@ -5,12 +5,17 @@ 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 app.utils import icon
from app.views._helpers import report_meta, safe
from core.tei_client import TEIClient
def render(client: TEIClient, tool: dict) -> None:
st.header("💸 Costs")
st.markdown(
f"<h2>{icon('receipt')} Costs</h2>",
unsafe_allow_html=True,
)
public_id = tool["id"]
report = report_meta(client, tool)
analysis_years = int(report.get("analysis_period_years") or 3)
@@ -32,7 +37,8 @@ def render(client: TEIClient, tool: dict) -> None:
col1, col2 = st.columns([1, 4])
with col1:
if st.button("💾 Save costs", use_container_width=True):
if st.button("Save costs", width="stretch"):
payload = df_to_values(edited, "costs", analysis_years)
result = safe(client.update_values, public_id, payload)
if result is not None:

View File

@@ -5,13 +5,19 @@ from __future__ import annotations
import streamlit as st
from app.components import charts
from app.pages._helpers import report_meta, safe
from app.locale import CURRENCY_SYMBOL, currency_fmt, fmt_currency
from app.utils import icon
from app.views._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")
st.markdown(
f"<h2>{icon('bar-chart-line')} Financial Summary</h2>",
unsafe_allow_html=True,
)
public_id = tool["id"]
report = report_meta(client, tool)
@@ -39,14 +45,14 @@ def render(client: TEIClient, tool: dict) -> None:
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[0].metric("NPV", f"{CURRENCY_SYMBOL}{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")
cols[3].metric("Benefits PV", f"{CURRENCY_SYMBOL}{bpv/1_000_000:,.1f}M")
cols[4].metric("Costs PV", f"{CURRENCY_SYMBOL}{cpv/1_000_000:,.1f}M")
st.divider()
@@ -64,7 +70,18 @@ def render(client: TEIClient, tool: dict) -> None:
if yb:
charts.cashflow(yb, initial_cost=initial)
with st.expander("Cash flow table"):
st.dataframe(yb, use_container_width=True, hide_index=True)
_cur = currency_fmt()
st.dataframe(
yb,
column_config={
"year": st.column_config.NumberColumn("Year", format="%d"),
"benefits": st.column_config.NumberColumn("Benefits", format=_cur),
"costs": st.column_config.NumberColumn("Costs", format=_cur),
"net": st.column_config.NumberColumn("Net", format=_cur),
},
width="stretch",
hide_index=True,
)
else:
st.caption("No yearly breakdown in this summary.")
@@ -94,11 +111,26 @@ def render(client: TEIClient, tool: dict) -> None:
}
for k, v in envelope["scenarios"].items()
]
st.dataframe(rows, use_container_width=True, hide_index=True)
_cur = currency_fmt()
st.dataframe(
rows,
column_config={
"Scenario": st.column_config.TextColumn("Scenario"),
"Benefits PV": st.column_config.NumberColumn("Benefits PV", format=_cur),
"Costs PV": st.column_config.NumberColumn("Costs PV", format=_cur),
"NPV": st.column_config.NumberColumn("NPV", format=_cur),
"ROI %": st.column_config.NumberColumn("ROI %", format="%.1f%%"),
"Payback (months)": st.column_config.NumberColumn(
"Payback (months)", format="%.1f"
),
},
width="stretch",
hide_index=True,
)
# Export button
st.divider()
if st.button("📦 Build export envelope (JSON)"):
if st.button("Build export envelope (JSON)"):
envelope = safe(
build_report_data,
client,

View File

@@ -4,7 +4,9 @@ from __future__ import annotations
import streamlit as st
from app.pages._helpers import safe
from app.utils import icon
from app.views._helpers import safe
from core.tei_client import TEIClient
@@ -17,6 +19,7 @@ def _diff_rows(a: dict[str, dict], b: dict[str, dict]) -> list[dict]:
"""Return one row per field with side-by-side year values."""
keys = sorted(set(a.keys()) | set(b.keys()))
rows: list[dict] = []
def _years_of(v: dict) -> dict:
"""Accept both friendly (year_values) and wire (nested years) shapes."""
if isinstance(v.get("year_values"), dict):
@@ -54,7 +57,10 @@ def _diff_rows(a: dict[str, dict], b: dict[str, dict]) -> list[dict]:
def render(client: TEIClient, tool: dict) -> None:
st.header("🕒 Versions")
st.markdown(
f"<h2>{icon('clock-history')} Versions</h2>",
unsafe_allow_html=True,
)
public_id = tool["id"]
versions = safe(client.list_versions, public_id) or []
@@ -63,7 +69,7 @@ def render(client: TEIClient, tool: dict) -> None:
)
# Save new version
with st.expander(" Save current state as a new version", expanded=not versions):
with st.expander("Save current state as a new version", expanded=not versions):
note = st.text_area(
"Version note",
placeholder=(
@@ -71,7 +77,7 @@ def render(client: TEIClient, tool: dict) -> None:
"raised legacy license cost from $160 to $180/agent.'"
),
)
if st.button("💾 Save version", disabled=not note.strip()):
if st.button("Save version", disabled=not note.strip()):
result = safe(client.save_version, public_id, note.strip())
if result:
st.success(
@@ -102,7 +108,8 @@ def render(client: TEIClient, tool: dict) -> None:
"Note": v.get("note", ""),
}
)
st.dataframe(rows, use_container_width=True, hide_index=True)
st.dataframe(rows, width="stretch", hide_index=True)
# Compare two versions
st.subheader("Compare")
@@ -132,4 +139,5 @@ def render(client: TEIClient, tool: dict) -> None:
if not diff:
st.success("No value differences between these versions.")
else:
st.dataframe(diff, use_container_width=True, hide_index=True)
st.dataframe(diff, width="stretch", hide_index=True)

View File

@@ -21,7 +21,7 @@
},
{
"cell_type": "code",
"execution_count": 5,
"execution_count": 2,
"id": "5bcc7740",
"metadata": {},
"outputs": [
@@ -58,7 +58,7 @@
},
{
"cell_type": "code",
"execution_count": 6,
"execution_count": 3,
"id": "386ae38b",
"metadata": {},
"outputs": [
@@ -111,7 +111,7 @@
},
{
"cell_type": "code",
"execution_count": 7,
"execution_count": 4,
"id": "dc46ab46",
"metadata": {},
"outputs": [
@@ -198,20 +198,89 @@
},
{
"cell_type": "code",
"execution_count": 12,
"execution_count": 5,
"id": "4070b9c2",
"metadata": {},
"outputs": [
{
"ename": "AttributeError",
"evalue": "'TEIClient' object has no attribute 'list_clients'",
"output_type": "error",
"traceback": [
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
"\u001b[31mAttributeError\u001b[39m Traceback (most recent call last)",
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[12]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m CLIENT_SEARCH = \u001b[33m\"Global\"\u001b[39m \u001b[38;5;66;03m# e.g. \"Acme\" — empty lists everyone\u001b[39;00m\n\u001b[32m 2\u001b[39m \n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m clients = client.list_clients(search=CLIENT_SEARCH \u001b[38;5;28;01mor\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m)\n\u001b[32m 4\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m clients:\n\u001b[32m 5\u001b[39m display(pd.DataFrame(clients)[\n\u001b[32m 6\u001b[39m [c for c in (\"id\", \"name\", \"vertical\", \"client_type\", \"employee_count\",\n",
"\u001b[31mAttributeError\u001b[39m: 'TEIClient' object has no attribute 'list_clients'"
]
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>id</th>\n",
" <th>name</th>\n",
" <th>vertical</th>\n",
" <th>client_type</th>\n",
" <th>employee_count</th>\n",
" <th>contact_center_agent_count</th>\n",
" <th>supervisor_count</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>2</td>\n",
" <td>Global Guardian Insurance</td>\n",
" <td>None</td>\n",
" <td>For-Profit</td>\n",
" <td>12000</td>\n",
" <td>2500</td>\n",
" <td>None</td>\n",
" </tr>\n",
" <tr>\n",
" <th>1</th>\n",
" <td>3</td>\n",
" <td>Eudaimonix</td>\n",
" <td>None</td>\n",
" <td>For-Profit</td>\n",
" <td>1500</td>\n",
" <td>300</td>\n",
" <td>None</td>\n",
" </tr>\n",
" <tr>\n",
" <th>2</th>\n",
" <td>4</td>\n",
" <td>Aetherium Forge</td>\n",
" <td>None</td>\n",
" <td>For-Profit</td>\n",
" <td>500</td>\n",
" <td>42</td>\n",
" <td>None</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" id name vertical client_type employee_count \\\n",
"0 2 Global Guardian Insurance None For-Profit 12000 \n",
"1 3 Eudaimonix None For-Profit 1500 \n",
"2 4 Aetherium Forge None For-Profit 500 \n",
"\n",
" contact_center_agent_count supervisor_count \n",
"0 2500 None \n",
"1 300 None \n",
"2 42 None "
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
@@ -230,24 +299,112 @@
},
{
"cell_type": "code",
"execution_count": 10,
"execution_count": 6,
"id": "4e97978c",
"metadata": {},
"outputs": [
{
"ename": "NameError",
"evalue": "name 'clients' is not defined",
"output_type": "error",
"traceback": [
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
"\u001b[31mNameError\u001b[39m Traceback (most recent call last)",
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[10]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m CLIENT_ID = \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;66;03m# ← set from the `id` column above, or leave for auto-pick\u001b[39;00m\n\u001b[32m 2\u001b[39m \n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m CLIENT_ID \u001b[38;5;28;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;28;01mand\u001b[39;00m len(clients) == \u001b[32m1\u001b[39m:\n\u001b[32m 4\u001b[39m CLIENT_ID = clients[\u001b[32m0\u001b[39m][\u001b[33m\"id\"\u001b[39m]\n\u001b[32m 5\u001b[39m print(f\"Auto-selected the only client: {clients[\u001b[32m0\u001b[39m][\u001b[33m'name'\u001b[39m]} (id={CLIENT_ID})\")\n\u001b[32m 6\u001b[39m \u001b[38;5;28;01massert\u001b[39;00m CLIENT_ID \u001b[38;5;28;01mis\u001b[39;00m \u001b[38;5;28;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m, \u001b[33m\"Set CLIENT_ID from the table above and re-run this cell.\"\u001b[39m\n",
"\u001b[31mNameError\u001b[39m: name 'clients' is not defined"
"name": "stdout",
"output_type": "stream",
"text": [
"\n",
"Client profile — no re-entry needed downstream:\n"
]
},
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>Global Guardian Insurance</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>id</th>\n",
" <td>2</td>\n",
" </tr>\n",
" <tr>\n",
" <th>name</th>\n",
" <td>Global Guardian Insurance</td>\n",
" </tr>\n",
" <tr>\n",
" <th>abbreviated_name</th>\n",
" <td>GGI</td>\n",
" </tr>\n",
" <tr>\n",
" <th>vertical</th>\n",
" <td>None</td>\n",
" </tr>\n",
" <tr>\n",
" <th>client_type</th>\n",
" <td>For-Profit</td>\n",
" </tr>\n",
" <tr>\n",
" <th>employee_count</th>\n",
" <td>12000</td>\n",
" </tr>\n",
" <tr>\n",
" <th>revenue</th>\n",
" <td>4500000000.0</td>\n",
" </tr>\n",
" <tr>\n",
" <th>contact_center_agent_count</th>\n",
" <td>2500</td>\n",
" </tr>\n",
" <tr>\n",
" <th>service_desk_agent_count</th>\n",
" <td>300</td>\n",
" </tr>\n",
" <tr>\n",
" <th>supervisor_count</th>\n",
" <td>None</td>\n",
" </tr>\n",
" <tr>\n",
" <th>location_count</th>\n",
" <td>120</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" Global Guardian Insurance\n",
"id 2\n",
"name Global Guardian Insurance\n",
"abbreviated_name GGI\n",
"vertical None\n",
"client_type For-Profit\n",
"employee_count 12000\n",
"revenue 4500000000.0\n",
"contact_center_agent_count 2500\n",
"service_desk_agent_count 300\n",
"supervisor_count None\n",
"location_count 120"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"CLIENT_ID = None # ← set from the `id` column above, or leave for auto-pick\n",
"CLIENT_ID = 2 # ← set from the `id` column above, or leave for auto-pick\n",
"\n",
"if CLIENT_ID is None and len(clients) == 1:\n",
" CLIENT_ID = clients[0][\"id\"]\n",
@@ -274,19 +431,60 @@
},
{
"cell_type": "code",
"execution_count": 11,
"execution_count": 7,
"id": "fcccc591",
"metadata": {},
"outputs": [
{
"ename": "NameError",
"evalue": "name 'profile' is not defined",
"output_type": "error",
"traceback": [
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
"\u001b[31mNameError\u001b[39m Traceback (most recent call last)",
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[11]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m CLIENT_ASSUMPTIONS = dict(seed.ASSUMPTIONS)\n\u001b[32m 2\u001b[39m overrides = {\n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[33m\"agents_fte\"\u001b[39m: profile.get(\u001b[33m\"contact_center_agent_count\"\u001b[39m),\n\u001b[32m 4\u001b[39m \u001b[33m\"supervisors_fte\"\u001b[39m: profile.get(\u001b[33m\"supervisor_count\"\u001b[39m),\n\u001b[32m 5\u001b[39m }\n\u001b[32m 6\u001b[39m rows = []\n",
"\u001b[31mNameError\u001b[39m: name 'profile' is not defined"
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>assumption</th>\n",
" <th>Forrester composite</th>\n",
" <th>Global Guardian Insurance (CRM)</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>agents_fte</td>\n",
" <td>2000</td>\n",
" <td>2500</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" assumption Forrester composite Global Guardian Insurance (CRM)\n",
"0 agents_fte 2000 2500"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"Indicative scale factor vs composite: 1.25× (apply judgement — benefits don't all scale linearly)\n"
]
}
],
@@ -326,10 +524,70 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 8,
"id": "57dec6cf",
"metadata": {},
"outputs": [],
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Proposals for Global Guardian Insurance:\n"
]
},
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>id</th>\n",
" <th>name</th>\n",
" <th>status</th>\n",
" <th>opportunity</th>\n",
" <th>due_date</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>1</td>\n",
" <td>Secure Cloud Infrastructure Modernization</td>\n",
" <td>Draft</td>\n",
" <td>Secure Cloud Infrastructure Modernization</td>\n",
" <td>2026-08-28</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" id name status \\\n",
"0 1 Secure Cloud Infrastructure Modernization Draft \n",
"\n",
" opportunity due_date \n",
"0 Secure Cloud Infrastructure Modernization 2026-08-28 "
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"proposals = client.proposals_for_client(CLIENT_ID)\n",
"engagements = client.engagements_for_client(CLIENT_NAME)\n",
@@ -356,10 +614,19 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 9,
"id": "19336bcc",
"metadata": {},
"outputs": [],
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Auto-selected proposal 1: Secure Cloud Infrastructure Modernization\n",
"Attaching via: {'proposal': 1}\n"
]
}
],
"source": [
"# Set exactly ONE of these (ids from the tables above). Leave both None to\n",
"# auto-pick — single existing proposal/engagement wins; otherwise a sandbox\n",
@@ -406,10 +673,18 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 10,
"id": "017ae9db",
"metadata": {},
"outputs": [],
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Created tool pkrsQ9SRf654 attached to {'proposal': 1}\n"
]
}
],
"source": [
"from core.tei_client import AthenaAPIError\n",
"\n",
@@ -452,10 +727,18 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 11,
"id": "20e2a736",
"metadata": {},
"outputs": [],
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Pushed values for 8 fields.\n"
]
}
],
"source": [
"payload = []\n",
"for b in seed.BENEFITS: # nominal; Athena risk-adjusts via the field definition\n",
@@ -487,10 +770,27 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 12,
"id": "b7ac5d24",
"metadata": {},
"outputs": [],
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"════════════════════════════════════════════════════════\n",
" TEI Financial Summary\n",
"════════════════════════════════════════════════════════\n",
" Total Benefits (PV): $ 101,696,568\n",
" Total Costs (PV): $ 22,874,326\n",
"────────────────────────────────────────────────────────\n",
" Net Present Value: $ 78,822,242\n",
" ROI: 345%\n",
" Payback: 1.0 months\n",
"════════════════════════════════════════════════════════\n"
]
}
],
"source": [
"summary = client.calculate(TOOL_ID)\n",
"client.print_summary(TOOL_ID)"
@@ -498,10 +798,90 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 13,
"id": "13d84001",
"metadata": {},
"outputs": [],
"outputs": [
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>metric</th>\n",
" <th>published</th>\n",
" <th>athena</th>\n",
" <th>diff</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>total_benefits_pv</td>\n",
" <td>101,696,791</td>\n",
" <td>101,696,568</td>\n",
" <td>-0.00%</td>\n",
" </tr>\n",
" <tr>\n",
" <th>1</th>\n",
" <td>total_costs_pv</td>\n",
" <td>22,983,076</td>\n",
" <td>22,874,326</td>\n",
" <td>-0.47%</td>\n",
" </tr>\n",
" <tr>\n",
" <th>2</th>\n",
" <td>net_present_value</td>\n",
" <td>78,713,715</td>\n",
" <td>78,822,242</td>\n",
" <td>+0.14%</td>\n",
" </tr>\n",
" <tr>\n",
" <th>3</th>\n",
" <td>roi_percentage</td>\n",
" <td>342</td>\n",
" <td>345</td>\n",
" <td>+0.76%</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" metric published athena diff\n",
"0 total_benefits_pv 101,696,791 101,696,568 -0.00%\n",
"1 total_costs_pv 22,983,076 22,874,326 -0.47%\n",
"2 net_present_value 78,713,715 78,822,242 +0.14%\n",
"3 roi_percentage 342 345 +0.76%"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"Payback: 1 months (published: <6 months)\n",
"✅ Verified — Athena reproduces the published Forrester totals.\n"
]
}
],
"source": [
"# Published Forrester totals (3-yr risk-adjusted PV @ 10%)\n",
"PUBLISHED = {\n",
@@ -539,10 +919,22 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 14,
"id": "148bdb2a",
"metadata": {},
"outputs": [],
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Saved version 1 (baseline).\n",
"Saved to /Users/robert/git/palladium/.env:\n",
" PALLADIUM_REPORT_PUBLIC_ID=xsUTbjh4iDnJ\n",
" PALLADIUM_TOOL_PUBLIC_ID=pkrsQ9SRf654\n",
" PALLADIUM_PROPOSAL_ID=1\n"
]
}
],
"source": [
"if not client.list_versions(TOOL_ID):\n",
" client.save_version(TOOL_ID, note=\"Baseline — published Forrester TEI figures (Feb 2026), moderate scenario.\")\n",
@@ -578,6 +970,14 @@
"- **Interactive editing** → `make app` / `streamlit run app/main.py` — the tool appears in the sidebar\n",
"- **CLI sanity check** → `python -m palladium summary $PALLADIUM_TOOL_PUBLIC_ID`"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "e9285087-5a2d-4a8d-856c-802474432892",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {