docs: add migration notebook and clarify export usage
This commit is contained in:
@@ -33,8 +33,9 @@ mercury --working-dir notebooks/
|
||||
# Or work on them directly in JupyterLab
|
||||
jupyter lab notebooks/
|
||||
|
||||
# Export the corrected business case as LLM-readable report sources
|
||||
# (exports/*.html for review, exports/*.md for feeding an LLM)
|
||||
# Export the business-case notebooks as LLM-readable report sources
|
||||
# (exports/*.html for review, exports/*.md for feeding an LLM;
|
||||
# optional filter: python scripts/export_report.py migration)
|
||||
python scripts/export_report.py
|
||||
|
||||
# Tests
|
||||
@@ -46,17 +47,18 @@ pytest
|
||||
**The notebooks are the deliverables.** All math lives in the pure-Python
|
||||
`tokencalc/` library; the notebooks are thin presentation layers over it.
|
||||
[Mercury](https://runmercury.com) serves them as interactive web apps — the
|
||||
`mercury` input widgets in `ctm_business_case_corrected.ipynb` let you tune
|
||||
`mercury` input widgets in the business-case notebooks let you tune
|
||||
contract values, termination dates, token assumptions, and implementation
|
||||
pricing live for a client, and headless runs (nbconvert, the section-10 regression
|
||||
gate) simply use the widget defaults. `scripts/export_report.py` executes the
|
||||
notebook and writes HTML + markdown to `exports/`; the notebook's section-12
|
||||
machine-readable appendix carries every number behind the figures so an LLM
|
||||
can draft the client report from the export.
|
||||
pricing live for a client, and headless runs (nbconvert, each notebook's
|
||||
regression-gate section) simply use the widget defaults. `scripts/export_report.py`
|
||||
executes the notebooks and writes HTML + markdown to `exports/`; each notebook's
|
||||
machine-readable appendix section carries every number behind the figures so an
|
||||
LLM can draft the client report from the export.
|
||||
|
||||
| Notebook | Purpose |
|
||||
|---|---|
|
||||
| `notebooks/ctm_business_case_corrected.ipynb` | Client-facing corrected business case (Mercury-interactive) |
|
||||
| `notebooks/ctm_migration_wfm.ipynb` | Migration + WFM only, all AI removed — the no-AI floor of the case (Mercury-interactive) |
|
||||
| `notebooks/ctm_token_calculator.ipynb` | Full token-cost / scenario workbench |
|
||||
|
||||
| Module | Purpose |
|
||||
|
||||
@@ -18,9 +18,10 @@ notebooks_button_label = "Analyses"
|
||||
header = "CTM × Genesys CCaaS"
|
||||
message = """
|
||||
Interactive business-case notebooks. **Corrected Business Case** keeps
|
||||
Genesys's claimed benefits verbatim and adds the costs the pitch omitted —
|
||||
tune the 🟡 inputs live for the client, then export the personalized report
|
||||
source with `python scripts/export_report.py`.
|
||||
Genesys's claimed benefits verbatim and adds the costs the pitch omitted;
|
||||
**Migration + WFM** strips out every AI capability and prices the platform
|
||||
move alone. Tune the 🟡 inputs live for the client, then export the
|
||||
personalized report source with `python scripts/export_report.py`.
|
||||
"""
|
||||
|
||||
[theme]
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,15 +1,17 @@
|
||||
"""Export the corrected business case notebook as LLM-readable report sources.
|
||||
"""Export the deliverable notebooks as LLM-readable report sources.
|
||||
|
||||
Executes the notebook fresh (widget defaults — or whatever defaults you edit in),
|
||||
Executes each notebook fresh (widget defaults — or whatever defaults you edit in),
|
||||
then writes both formats to exports/:
|
||||
|
||||
exports/ctm_business_case_corrected.html — human-reviewable, tables render
|
||||
exports/ctm_business_case_corrected.md — leanest LLM input
|
||||
exports/<notebook>.html — human-reviewable, tables render
|
||||
exports/<notebook>.md — leanest LLM input
|
||||
|
||||
Plotly figures export as JavaScript an LLM cannot read; the notebook's section-12
|
||||
machine-readable appendix carries every number behind them.
|
||||
Plotly figures export as JavaScript an LLM cannot read; each notebook's
|
||||
machine-readable appendix section carries every number behind them.
|
||||
|
||||
Run from the project root: python scripts/export_report.py
|
||||
Run from the project root: python scripts/export_report.py [name-filter]
|
||||
An optional argument exports only notebooks whose filename contains it,
|
||||
e.g. python scripts/export_report.py migration
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,18 +20,26 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NOTEBOOK = ROOT / "notebooks" / "ctm_business_case_corrected.ipynb"
|
||||
NOTEBOOKS = [
|
||||
ROOT / "notebooks" / "ctm_business_case_corrected.ipynb",
|
||||
ROOT / "notebooks" / "ctm_migration_wfm.ipynb",
|
||||
]
|
||||
EXPORTS = ROOT / "exports"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
picked = [nb for nb in NOTEBOOKS
|
||||
if len(sys.argv) < 2 or sys.argv[1] in nb.name]
|
||||
if not picked:
|
||||
sys.exit(f"no notebook matches {sys.argv[1]!r}")
|
||||
EXPORTS.mkdir(exist_ok=True)
|
||||
for fmt in ("html", "markdown"):
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "nbconvert", "--execute",
|
||||
"--to", fmt, "--output-dir", str(EXPORTS), str(NOTEBOOK)],
|
||||
check=True, cwd=ROOT,
|
||||
)
|
||||
for nb in picked:
|
||||
for fmt in ("html", "markdown"):
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "nbconvert", "--execute",
|
||||
"--to", fmt, "--output-dir", str(EXPORTS), str(nb)],
|
||||
check=True, cwd=ROOT,
|
||||
)
|
||||
for p in sorted(EXPORTS.iterdir()):
|
||||
if p.suffix in (".html", ".md"):
|
||||
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Migration + WFM (no-AI) scenario — hand-check acceptance numbers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc import appendix4 as a4
|
||||
from tokencalc import migration_wfm as mw
|
||||
from tokencalc.defaults import CTM_DEFAULT_SITES
|
||||
|
||||
SITES = list(CTM_DEFAULT_SITES)
|
||||
|
||||
|
||||
def _default_benefit_rollout():
|
||||
_, _, benefit_rollout = a4.build_rollouts(SITES)
|
||||
return benefit_rollout
|
||||
|
||||
|
||||
def test_wfm_scope_and_verbatim_total():
|
||||
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
|
||||
assert set(ben["capability"]) == {"WFM"}
|
||||
assert set(ben["region"]) == set(mw.DEFAULT_WFM_REGIONS)
|
||||
assert "EMEA" not in set(ben["region"]), "EMEA WFM is out of scope"
|
||||
# NA $0 (migration) + ANZ $1.4M + ASIA $914K — verbatim, exact.
|
||||
assert ben["benefit"].sum() == pytest.approx(2_314_000)
|
||||
|
||||
|
||||
def test_wfm_phasing_on_deck_schedule():
|
||||
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
|
||||
by_year = ben.groupby("year")["benefit"].sum()
|
||||
assert by_year[2026] == 0.0
|
||||
# ANZ realizes Dec 2027 (1 of 13 live months lands in 2027).
|
||||
assert by_year[2027] == pytest.approx(1_400_000 / 13)
|
||||
assert by_year[2028] == pytest.approx(2_314_000 - 1_400_000 / 13)
|
||||
|
||||
|
||||
def test_runrate_saving_annual():
|
||||
# (7.3M − 4.3M) licence + 1.3M ANZ + 1.6M ASIA + 0 NA = 5.9M.
|
||||
assert mw.wfm_annual_runrate() == pytest.approx(2_900_000)
|
||||
assert mw.runrate_saving_annual() == pytest.approx(5_900_000)
|
||||
assert mw.runrate_saving_annual(regions=["NA"]) == pytest.approx(3_000_000)
|
||||
assert mw.runrate_saving_annual(licence_annual=4_800_000,
|
||||
regions=[]) == pytest.approx(2_500_000)
|
||||
|
||||
|
||||
def test_breakeven_extrapolates_past_window():
|
||||
cs = a4.current_state_inputs(SITES)
|
||||
cur = a4.current_costs_by_year(cs)
|
||||
lic = a4.licence_costs_by_year()
|
||||
ps = a4.ps_costs_by_year()
|
||||
total = {y: cur[y] + lic[y] + ps[y] for y in a4.YEARS}
|
||||
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
|
||||
ben_y = {y: float(ben.loc[ben.year == y, "benefit"].sum()) for y in a4.YEARS}
|
||||
inc, net = a4.case_flows(total, ben_y)
|
||||
assert sum(net.values()) == pytest.approx(-1_553_000, abs=1_000)
|
||||
label = mw.runrate_breakeven_label(net, mw.runrate_saving_annual())
|
||||
assert label == "40 months (~Apr 2029, extrapolated)"
|
||||
|
||||
|
||||
def test_breakeven_defers_in_window_and_guards_zero_runrate():
|
||||
positive = {2026: 1_000_000.0, 2027: 0.0, 2028: 0.0}
|
||||
assert mw.runrate_breakeven_label(positive, 5_900_000) == \
|
||||
a4.payback_label(positive)
|
||||
negative = {2026: -1_000_000.0, 2027: 0.0, 2028: 0.0}
|
||||
assert mw.runrate_breakeven_label(negative, 0.0) == \
|
||||
"never at current run-rate"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Migration + WFM scenario — the no-AI business case.
|
||||
|
||||
Current state → Genesys Cloud CX migration, NA users migrated onto
|
||||
Genesys WFM, WFM implemented for APAC (ANZ + ASIA). Migration and WFM
|
||||
are included in the base implementation price (the verbatim PS +
|
||||
training), so the only cost lines are the existing-platform run-off,
|
||||
the licence ramp, and base PS — no token consumption, no AI
|
||||
implementation labour. The only benefits kept are the deck's verbatim
|
||||
WFM lines for the regions in WFM scope.
|
||||
|
||||
Single source of truth behind ``notebooks/ctm_migration_wfm.ipynb``
|
||||
(served with Mercury) — the presentation layer holds no math. All
|
||||
primitives come from :mod:`tokencalc.appendix4`; this module only
|
||||
scopes and extrapolates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from . import appendix4 as a4
|
||||
|
||||
#: WFM scope in this scenario. NA is a migration off its existing
|
||||
#: WFM-like tool (verbatim benefit $0 — "has similar feature"); ANZ and
|
||||
#: ASIA are new implementations ("APAC"); EMEA is out of scope, so the
|
||||
#: deck's EMEA WFM benefit line is dropped.
|
||||
DEFAULT_WFM_REGIONS = ["NA", "ANZ", "ASIA"]
|
||||
|
||||
|
||||
def wfm_benefits_by_year(
|
||||
benefit_rollout, regions: list[str] | None = None
|
||||
) -> pd.DataFrame:
|
||||
"""Verbatim WFM benefits for the scoped regions, phased on the
|
||||
deck's deployment schedule (realize = impl + 3 months, inclusive).
|
||||
|
||||
Long DataFrame: region, capability, year, benefit — the WFM slice
|
||||
of :func:`tokencalc.appendix4.benefits_by_year`.
|
||||
"""
|
||||
scope = DEFAULT_WFM_REGIONS if regions is None else regions
|
||||
df = a4.benefits_by_year(benefit_rollout)
|
||||
return df[(df["capability"] == "WFM")
|
||||
& (df["region"].isin(scope))].reset_index(drop=True)
|
||||
|
||||
|
||||
def wfm_annual_runrate(regions: list[str] | None = None) -> float:
|
||||
"""Sum of the verbatim WFM *annual* values for the scoped regions."""
|
||||
scope = DEFAULT_WFM_REGIONS if regions is None else regions
|
||||
return float(sum(annual for (r, c), (annual, _t) in
|
||||
a4.VERBATIM_BENEFITS.items()
|
||||
if c == "WFM" and r in scope))
|
||||
|
||||
|
||||
def runrate_saving_annual(
|
||||
licence_annual: float | None = None,
|
||||
regions: list[str] | None = None,
|
||||
baseline_annual: float | None = None,
|
||||
) -> float:
|
||||
"""Steady-state annual saving once term contracts end and the ramp
|
||||
is over: (baseline − licence run-rate) + scoped WFM annual values.
|
||||
"""
|
||||
lic = a4.TCO_VERBATIM["ccaas_annual"] if licence_annual is None else licence_annual
|
||||
base = a4.TCO_VERBATIM["current_annual"] if baseline_annual is None else baseline_annual
|
||||
return (base - lic) + wfm_annual_runrate(regions)
|
||||
|
||||
|
||||
def runrate_breakeven_label(
|
||||
net_by_year: dict[int, float], runrate_annual: float
|
||||
) -> str:
|
||||
"""Payback label, extrapolated past the model window at a run-rate.
|
||||
|
||||
Inside 2026-28 this defers to :func:`tokencalc.appendix4.payback_label`;
|
||||
a deficit at end-2028 fills at ``runrate_annual`` per year.
|
||||
"""
|
||||
deficit = -sum(net_by_year[y] for y in a4.YEARS)
|
||||
if deficit <= 0:
|
||||
return a4.payback_label(net_by_year)
|
||||
if runrate_annual <= 0:
|
||||
return "never at current run-rate"
|
||||
m = 12 * len(a4.YEARS) + math.ceil(12 * deficit / runrate_annual)
|
||||
return f"{m} months (~{a4.month_label(m)}, extrapolated)"
|
||||
Reference in New Issue
Block a user