Files
palladium/core/export/report_data.py
Robert Helewka e88449d15a Migrate Genesys CX Cloud TEI study to the pattern; retire Streamlit app
studies/202512_GenesysCX -> studies/202512_TEI_Genesys_CX_Cloud,
rebuilt as pattern Variant 4 (TEI composite reproduction):

- teicalc/ self-contained engine: Forrester's tables as the never-edited
  verbatim anchor (incl. the p.14 typo note and the $0 AI-token line),
  generic model/scenarios/staging carried over from the Amazon Connect
  study, ClientDrivers overlay (agents / weekly interactions / revenue,
  flat composite so no growth re-base) with ai_tokens_annual as a direct
  input for the token line the published study models at $0
- one deliverable notebook (business_case.ipynb): widget-pair sidebar
  drivers incl. the AI-token price, published-vs-overlay KPI columns,
  cash-flow/waterfall/scenario charts, verification gate, backstage JSON
  data appendix
- gate + tests reproduce the published totals within $2: NPV $10.8M /
  ROI 266% (engine $10,783,466 / 265.79%; payback 3.3 months, not
  headlined in the PDF); 29 study tests, headless nbconvert green,
  stage simulation leak-free, exports carry the appendix
- old Athena workflow (00_provision..04_export, config.py, seed_data.py,
  PALLADIUM_GENESYSCX_* keys, ATHENA_EXPECTED reconciliation) deleted;
  git history preserves it

With the last legacy study migrated, the retirement lands too:
- app/ (Streamlit UI) and core/notebook_helpers deleted; nothing else
  imported them
- streamlit stripped from pyproject extras, requirements.txt, Makefile;
  .env.example reduced to the Athena keys; 00_setup.ipynb and
  core/bootstrap.py repointed at the pattern studies
- root README reworked: self-contained studies + slim core/ Athena
  toolkit (tei_client, calculations, export, cli)

All suites green: Genesys 29, Amazon Connect 27, CTM 55, template 7,
root 58.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 16:38:51 -04:00

225 lines
7.4 KiB
Python
Raw Blame History

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