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

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

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

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

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

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

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

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

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

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

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

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

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