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:
13
.env.example
13
.env.example
@@ -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=.
|
||||
|
||||
|
||||
105
00_setup.ipynb
105
00_setup.ipynb
@@ -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": {
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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":
|
||||
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 _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%)",
|
||||
)
|
||||
if table == "costs":
|
||||
column_config["Initial"] = st.column_config.NumberColumn(
|
||||
"Initial", format="$%.0f"
|
||||
"Initial", format=_cur_fmt
|
||||
)
|
||||
for y in years:
|
||||
column_config[f"Year {y}"] = st.column_config.NumberColumn(
|
||||
f"Year {y}", format="$%.0f"
|
||||
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
87
app/locale.py
Normal 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 (0–1) 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* (0–1 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:]}%"
|
||||
34
app/main.py
34
app/main.py
@@ -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
45
app/utils.py
Normal 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>'
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user