diff --git a/.env.example b/.env.example index 2211e0e..24ddc8a 100644 --- a/.env.example +++ b/.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=. + diff --git a/00_setup.ipynb b/00_setup.ipynb index 7aad243..b6faf8f 100644 --- a/00_setup.ipynb +++ b/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": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
idnamevendorversionstatusanalysis_period_yearsdiscount_ratefield_countinstance_count
0xsUTbjh4iDnJAmazon Connect 2026AWS1.0active30.1000110
\n", + "
" + ], + "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": { diff --git a/README.md b/README.md index 32e0c88..b7bb5dd 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/app/components/charts.py b/app/components/charts.py index e278fc5..edd4c6d 100644 --- a/app/components/charts.py +++ b/app/components/charts.py @@ -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") diff --git a/app/components/tables.py b/app/components/tables.py index 0e35f0b..009fb1f 100644 --- a/app/components/tables.py +++ b/app/components/tables.py @@ -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, diff --git a/app/locale.py b/app/locale.py new file mode 100644 index 0000000..99f1764 --- /dev/null +++ b/app/locale.py @@ -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:]}%" diff --git a/app/main.py b/app/main.py index 1246907..51ef145 100644 --- a/app/main.py +++ b/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"

{icon('shield-fill')} Palladium — TEI Calculator

", + 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) diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..739d36d --- /dev/null +++ b/app/utils.py @@ -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 = """ + + +""" + + +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 ```` 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'' diff --git a/app/views/benefits.py b/app/views/benefits.py index 255d0ac..9c56e22 100644 --- a/app/views/benefits.py +++ b/app/views/benefits.py @@ -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"

{icon('graph-up-arrow')} Benefits

", + 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: diff --git a/app/views/costs.py b/app/views/costs.py index 24e8185..3eb0fe0 100644 --- a/app/views/costs.py +++ b/app/views/costs.py @@ -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"

{icon('receipt')} Costs

", + 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: diff --git a/app/views/summary.py b/app/views/summary.py index eb39f57..fd6139e 100644 --- a/app/views/summary.py +++ b/app/views/summary.py @@ -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"

{icon('bar-chart-line')} Financial Summary

", + 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, diff --git a/app/views/versions.py b/app/views/versions.py index fb5a34f..cfaa923 100644 --- a/app/views/versions.py +++ b/app/views/versions.py @@ -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"

{icon('clock-history')} Versions

", + 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) + diff --git a/studies/202602_AmazonConnect/notebooks/00_provision.ipynb b/studies/202602_AmazonConnect/notebooks/00_provision.ipynb index 7e447f2..bd18684 100644 --- a/studies/202602_AmazonConnect/notebooks/00_provision.ipynb +++ b/studies/202602_AmazonConnect/notebooks/00_provision.ipynb @@ -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": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
idnameverticalclient_typeemployee_countcontact_center_agent_countsupervisor_count
02Global Guardian InsuranceNoneFor-Profit120002500None
13EudaimonixNoneFor-Profit1500300None
24Aetherium ForgeNoneFor-Profit50042None
\n", + "
" + ], + "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": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Global Guardian Insurance
id2
nameGlobal Guardian Insurance
abbreviated_nameGGI
verticalNone
client_typeFor-Profit
employee_count12000
revenue4500000000.0
contact_center_agent_count2500
service_desk_agent_count300
supervisor_countNone
location_count120
\n", + "
" + ], + "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": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
assumptionForrester compositeGlobal Guardian Insurance (CRM)
0agents_fte20002500
\n", + "
" + ], + "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": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
idnamestatusopportunitydue_date
01Secure Cloud Infrastructure ModernizationDraftSecure Cloud Infrastructure Modernization2026-08-28
\n", + "
" + ], + "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": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
metricpublishedathenadiff
0total_benefits_pv101,696,791101,696,568-0.00%
1total_costs_pv22,983,07622,874,326-0.47%
2net_present_value78,713,71578,822,242+0.14%
3roi_percentage342345+0.76%
\n", + "
" + ], + "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": {